Compare commits
2 Commits
2a1a42fd9c
...
8dd8e3e5f4
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dd8e3e5f4 | |||
| 5bacdcc2fc |
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/factuges-server",
|
"name": "@erp/factuges-server",
|
||||||
"version": "0.0.12",
|
"version": "0.0.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts --config tsup.config.ts",
|
"build": "tsup src/index.ts --config tsup.config.ts",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
import { asBoolean, asNumber, required } from "./config-helpers";
|
import { asBoolean, asNumber, required } from "./config-helpers";
|
||||||
|
|
||||||
// Carga de variables de entorno (.env). Si ya están en el entorno, no se sobreescriben.
|
// Carga de variables de entorno (.env). Si ya están en el entorno, no se sobreescriben.
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/factuges-web",
|
"name": "@erp/factuges-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.12",
|
"version": "0.0.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host --clearScreen false",
|
"dev": "vite --host --clearScreen false",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/auth",
|
"name": "@erp/auth",
|
||||||
"version": "0.0.12",
|
"version": "0.0.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/core",
|
"name": "@erp/core",
|
||||||
"version": "0.0.12",
|
"version": "0.0.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/customer-invoices",
|
"name": "@erp/customer-invoices",
|
||||||
"version": "0.0.12",
|
"version": "0.0.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export * from "./get-issue-invoice.use-case";
|
export * from "./get-issue-invoice.use-case";
|
||||||
|
export * from "./list-issue-invoices.use-case";
|
||||||
export * from "./report-issue-invoice.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);
|
return Result.fail(result.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerInvoices = result.data;
|
const proformas = result.data;
|
||||||
const dto = presenter.toOutput({
|
const dto = presenter.toOutput({
|
||||||
customerInvoices,
|
customerInvoices: proformas,
|
||||||
criteria,
|
criteria,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export class ReportProformaUseCase {
|
|||||||
return Result.fail(idOrError.error);
|
return Result.fail(idOrError.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoiceId = idOrError.data;
|
const proformaId = idOrError.data;
|
||||||
const pdfPresenter = this.presenterRegistry.getPresenter({
|
const pdfPresenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer-invoice",
|
resource: "customer-invoice",
|
||||||
projection: "REPORT",
|
projection: "REPORT",
|
||||||
@ -38,7 +38,7 @@ export class ReportProformaUseCase {
|
|||||||
try {
|
try {
|
||||||
const proformaOrError = await this.service.getProformaByIdInCompany(
|
const proformaOrError = await this.service.getProformaByIdInCompany(
|
||||||
companyId,
|
companyId,
|
||||||
invoiceId,
|
proformaId,
|
||||||
transaction
|
transaction
|
||||||
);
|
);
|
||||||
if (proformaOrError.isFailure) {
|
if (proformaOrError.isFailure) {
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { readFileSync } from "node:fs";
|
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 { Presenter } from "@erp/core/api";
|
||||||
import * as handlebars from "handlebars";
|
import Handlebars from "handlebars";
|
||||||
|
|
||||||
import type { CustomerInvoice } from "../../../../../domain";
|
import type { CustomerInvoice } from "../../../../../domain";
|
||||||
import type {
|
import type {
|
||||||
@ -10,6 +11,18 @@ import type {
|
|||||||
CustomerInvoiceReportPresenter,
|
CustomerInvoiceReportPresenter,
|
||||||
} from "../../../../presenters";
|
} 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 {
|
export class CustomerInvoiceReportHTMLPresenter extends Presenter {
|
||||||
toOutput(customerInvoice: CustomerInvoice): string {
|
toOutput(customerInvoice: CustomerInvoice): string {
|
||||||
const dtoPresenter = this.presenterRegistry.getPresenter({
|
const dtoPresenter = this.presenterRegistry.getPresenter({
|
||||||
@ -27,10 +40,11 @@ export class CustomerInvoiceReportHTMLPresenter extends Presenter {
|
|||||||
const prettyDTO = prePresenter.toOutput(invoiceDTO);
|
const prettyDTO = prePresenter.toOutput(invoiceDTO);
|
||||||
|
|
||||||
// Obtener y compilar la plantilla HTML
|
// Obtener y compilar la plantilla HTML
|
||||||
const templateHtml = readFileSync(
|
const here = fromHere(import.meta.url);
|
||||||
path.join(__dirname, "./templates/customer-invoice/template.hbs")
|
|
||||||
).toString();
|
const templatePath = here.resolve("./templates/customer-invoice/template.hbs");
|
||||||
const template = handlebars.compile(templateHtml, {});
|
const templateHtml = readFileSync(templatePath).toString();
|
||||||
|
const template = Handlebars.compile(templateHtml, {});
|
||||||
return template(prettyDTO);
|
return template(prettyDTO);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,8 @@ export class CustomerInvoiceReportPDFPresenter extends Presenter<
|
|||||||
|
|
||||||
// Generar el PDF con Puppeteer
|
// Generar el PDF con Puppeteer
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
|
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||||
|
headless: true,
|
||||||
args: [
|
args: [
|
||||||
"--disable-extensions",
|
"--disable-extensions",
|
||||||
"--no-sandbox",
|
"--no-sandbox",
|
||||||
|
|||||||
@ -20,11 +20,14 @@ import {
|
|||||||
CustomerInvoiceReportPresenter,
|
CustomerInvoiceReportPresenter,
|
||||||
CustomerInvoiceTaxesReportPresenter,
|
CustomerInvoiceTaxesReportPresenter,
|
||||||
DeleteProformaUseCase,
|
DeleteProformaUseCase,
|
||||||
|
GetIssueInvoiceUseCase,
|
||||||
GetProformaUseCase,
|
GetProformaUseCase,
|
||||||
IssueProformaInvoiceUseCase,
|
IssueProformaInvoiceUseCase,
|
||||||
ListCustomerInvoicesPresenter,
|
ListCustomerInvoicesPresenter,
|
||||||
|
ListIssueInvoicesUseCase,
|
||||||
ListProformasUseCase,
|
ListProformasUseCase,
|
||||||
RecipientInvoiceFullPresenter,
|
RecipientInvoiceFullPresenter,
|
||||||
|
ReportIssueInvoiceUseCase,
|
||||||
ReportProformaUseCase,
|
ReportProformaUseCase,
|
||||||
UpdateProformaUseCase,
|
UpdateProformaUseCase,
|
||||||
} from "../application";
|
} from "../application";
|
||||||
@ -51,6 +54,10 @@ export type CustomerInvoiceDeps = {
|
|||||||
report_proforma: () => ReportProformaUseCase;
|
report_proforma: () => ReportProformaUseCase;
|
||||||
issue_proforma: () => IssueProformaInvoiceUseCase;
|
issue_proforma: () => IssueProformaInvoiceUseCase;
|
||||||
changeStatus_proforma: () => ChangeStatusProformaUseCase;
|
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"] = {
|
const useCases: CustomerInvoiceDeps["useCases"] = {
|
||||||
|
// Proformas
|
||||||
list_proformas: () =>
|
list_proformas: () =>
|
||||||
new ListProformasUseCase(appService, transactionManager, presenterRegistry),
|
new ListProformasUseCase(appService, transactionManager, presenterRegistry),
|
||||||
get_proforma: () => new GetProformaUseCase(appService, transactionManager, presenterRegistry),
|
get_proforma: () => new GetProformaUseCase(appService, transactionManager, presenterRegistry),
|
||||||
@ -138,6 +146,14 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer
|
|||||||
issue_proforma: () =>
|
issue_proforma: () =>
|
||||||
new IssueProformaInvoiceUseCase(appService, transactionManager, presenterRegistry),
|
new IssueProformaInvoiceUseCase(appService, transactionManager, presenterRegistry),
|
||||||
changeStatus_proforma: () => new ChangeStatusProformaUseCase(appService, transactionManager),
|
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 {
|
return {
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
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";
|
import { customerInvoicesApiErrorMapper } from "../../customer-invoices-api-error-mapper";
|
||||||
|
|
||||||
export class GetIssueInvoiceController extends ExpressController {
|
export class GetIssueInvoiceController extends ExpressController {
|
||||||
public constructor(private readonly useCase: GetProformaUseCase) {
|
public constructor(private readonly useCase: GetIssueInvoiceUseCase) {
|
||||||
super();
|
super();
|
||||||
this.errorMapper = customerInvoicesApiErrorMapper;
|
this.errorMapper = customerInvoicesApiErrorMapper;
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ export class GetIssueInvoiceController extends ExpressController {
|
|||||||
}
|
}
|
||||||
const { invoice_id } = this.req.params;
|
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(
|
return result.match(
|
||||||
(data) => this.ok(data),
|
(data) => this.ok(data),
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export * from "./get-issue-invoice.controller";
|
export * from "./get-issue-invoice.controller";
|
||||||
export * from "./list-issue-invoices.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 { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||||
import { Criteria } from "@repo/rdx-criteria/server";
|
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";
|
import { customerInvoicesApiErrorMapper } from "../../customer-invoices-api-error-mapper";
|
||||||
|
|
||||||
export class ListIssueInvoicesController extends ExpressController {
|
export class ListIssueInvoicesController extends ExpressController {
|
||||||
public constructor(private readonly useCase: ListProformasUseCase) {
|
public constructor(private readonly useCase: ListIssueInvoicesUseCase) {
|
||||||
super();
|
super();
|
||||||
this.errorMapper = customerInvoicesApiErrorMapper;
|
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 { buildCustomerInvoiceDependencies } from "../dependencies";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
GetProformaController,
|
GetIssueInvoiceController,
|
||||||
ListProformasController,
|
ListIssueInvoicesController,
|
||||||
ReportProformaController,
|
ReportIssueInvoiceController,
|
||||||
} from "./controllers";
|
} from "./controllers";
|
||||||
|
|
||||||
export const issueInvoicesRouter = (params: ModuleParams) => {
|
export const issueInvoicesRouter = (params: ModuleParams) => {
|
||||||
@ -51,8 +51,8 @@ export const issueInvoicesRouter = (params: ModuleParams) => {
|
|||||||
//checkTabContext,
|
//checkTabContext,
|
||||||
validateRequest(ListIssueInvoicesRequestSchema, "params"),
|
validateRequest(ListIssueInvoicesRequestSchema, "params"),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const useCase = deps.useCases.list_proformas();
|
const useCase = deps.useCases.list_issue_invoices();
|
||||||
const controller = new ListProformasController(useCase /*, deps.presenters.list */);
|
const controller = new ListIssueInvoicesController(useCase /*, deps.presenters.list */);
|
||||||
return controller.execute(req, res, next);
|
return controller.execute(req, res, next);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -62,8 +62,8 @@ export const issueInvoicesRouter = (params: ModuleParams) => {
|
|||||||
//checkTabContext,
|
//checkTabContext,
|
||||||
validateRequest(GetIssueInvoiceByIdRequestSchema, "params"),
|
validateRequest(GetIssueInvoiceByIdRequestSchema, "params"),
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const useCase = deps.useCases.get_proforma();
|
const useCase = deps.useCases.get_issue_invoice();
|
||||||
const controller = new GetProformaController(useCase);
|
const controller = new GetIssueInvoiceController(useCase);
|
||||||
return controller.execute(req, res, next);
|
return controller.execute(req, res, next);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -73,11 +73,11 @@ export const issueInvoicesRouter = (params: ModuleParams) => {
|
|||||||
//checkTabContext,
|
//checkTabContext,
|
||||||
validateRequest(ReportIssueInvoiceByIdRequestSchema, "params"),
|
validateRequest(ReportIssueInvoiceByIdRequestSchema, "params"),
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const useCase = deps.useCases.report_proforma();
|
const useCase = deps.useCases.report_issue_invoice();
|
||||||
const controller = new ReportProformaController(useCase);
|
const controller = new ReportIssueInvoiceController(useCase);
|
||||||
return controller.execute(req, res, next);
|
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 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 "./customer-invoice.repository";
|
||||||
export * from "./models";
|
export * from "./models";
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { Criteria } from "@repo/rdx-criteria/server";
|
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
|
// Campos físicos (DB) que permitimos filtrar/ordenar
|
||||||
const ALLOWED_FILTERS = {
|
const ALLOWED_FILTERS = {
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
DataTypes,
|
DataTypes,
|
||||||
InferAttributes,
|
type InferAttributes,
|
||||||
InferCreationAttributes,
|
type InferCreationAttributes,
|
||||||
Model,
|
Model,
|
||||||
NonAttribute,
|
type NonAttribute,
|
||||||
Sequelize,
|
type Sequelize,
|
||||||
} from "sequelize";
|
} from "sequelize";
|
||||||
import { CustomerInvoiceItem } from "../../../domain";
|
|
||||||
|
import type { CustomerInvoiceItem } from "../../../domain";
|
||||||
|
|
||||||
export type CustomerInvoiceItemTaxCreationAttributes = InferCreationAttributes<
|
export type CustomerInvoiceItemTaxCreationAttributes = InferCreationAttributes<
|
||||||
CustomerInvoiceItemTaxModel,
|
CustomerInvoiceItemTaxModel,
|
||||||
|
|||||||
@ -1,17 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
CreationOptional,
|
type CreationOptional,
|
||||||
DataTypes,
|
DataTypes,
|
||||||
InferAttributes,
|
type InferAttributes,
|
||||||
InferCreationAttributes,
|
type InferCreationAttributes,
|
||||||
Model,
|
Model,
|
||||||
NonAttribute,
|
type NonAttribute,
|
||||||
Sequelize,
|
type Sequelize,
|
||||||
} from "sequelize";
|
} from "sequelize";
|
||||||
import {
|
|
||||||
|
import type { CustomerInvoiceModel } from "./customer-invoice.model";
|
||||||
|
import type {
|
||||||
CustomerInvoiceItemTaxCreationAttributes,
|
CustomerInvoiceItemTaxCreationAttributes,
|
||||||
CustomerInvoiceItemTaxModel,
|
CustomerInvoiceItemTaxModel,
|
||||||
} from "./customer-invoice-item-tax.model";
|
} from "./customer-invoice-item-tax.model";
|
||||||
import { CustomerInvoiceModel } from "./customer-invoice.model";
|
|
||||||
|
|
||||||
export type CustomerInvoiceItemCreationAttributes = InferCreationAttributes<
|
export type CustomerInvoiceItemCreationAttributes = InferCreationAttributes<
|
||||||
CustomerInvoiceItemModel,
|
CustomerInvoiceItemModel,
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
DataTypes,
|
DataTypes,
|
||||||
InferAttributes,
|
type InferAttributes,
|
||||||
InferCreationAttributes,
|
type InferCreationAttributes,
|
||||||
Model,
|
Model,
|
||||||
NonAttribute,
|
type NonAttribute,
|
||||||
Sequelize,
|
type Sequelize,
|
||||||
} from "sequelize";
|
} from "sequelize";
|
||||||
import { CustomerInvoice } from "../../../domain";
|
|
||||||
|
import type { CustomerInvoice } from "../../../domain";
|
||||||
|
|
||||||
export type CustomerInvoiceTaxCreationAttributes = InferCreationAttributes<
|
export type CustomerInvoiceTaxCreationAttributes = InferCreationAttributes<
|
||||||
CustomerInvoiceTaxModel,
|
CustomerInvoiceTaxModel,
|
||||||
|
|||||||
@ -1,20 +1,19 @@
|
|||||||
import { CustomerModel } from "@erp/customers/api";
|
import type { CustomerModel } from "@erp/customers/api";
|
||||||
import {
|
import {
|
||||||
CreationOptional,
|
type CreationOptional,
|
||||||
DataTypes,
|
DataTypes,
|
||||||
InferAttributes,
|
type InferAttributes,
|
||||||
InferCreationAttributes,
|
type InferCreationAttributes,
|
||||||
Model,
|
Model,
|
||||||
NonAttribute,
|
type NonAttribute,
|
||||||
Sequelize,
|
type Sequelize,
|
||||||
} from "sequelize";
|
} from "sequelize";
|
||||||
|
|
||||||
import {
|
import type {
|
||||||
CustomerInvoiceItemCreationAttributes,
|
CustomerInvoiceItemCreationAttributes,
|
||||||
CustomerInvoiceItemModel,
|
CustomerInvoiceItemModel,
|
||||||
} from "./customer-invoice-item.model";
|
} from "./customer-invoice-item.model";
|
||||||
|
import type {
|
||||||
import {
|
|
||||||
CustomerInvoiceTaxCreationAttributes,
|
CustomerInvoiceTaxCreationAttributes,
|
||||||
CustomerInvoiceTaxModel,
|
CustomerInvoiceTaxModel,
|
||||||
} from "./customer-invoice-tax.model";
|
} from "./customer-invoice-tax.model";
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { UniqueID } from "@repo/rdx-ddd";
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||||
import { literal, Transaction, WhereOptions } from "sequelize";
|
import { type Transaction, type WhereOptions, literal } from "sequelize";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CustomerInvoiceNumber,
|
CustomerInvoiceNumber,
|
||||||
CustomerInvoiceSerie,
|
type CustomerInvoiceSerie,
|
||||||
ICustomerInvoiceNumberGenerator,
|
type ICustomerInvoiceNumberGenerator,
|
||||||
} from "../../domain";
|
} from "../../domain";
|
||||||
import { CustomerInvoiceModel } from "../sequelize";
|
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,
|
UpdateCustomerInvoiceByIdRequestDTO,
|
||||||
} from "../../common";
|
} from "../../common";
|
||||||
import type { InvoiceContextValue } from "../context";
|
import type { InvoiceContextValue } from "../context";
|
||||||
|
import type { InvoiceFormData } from "../schemas/invoice.form.schema";
|
||||||
import type { InvoiceFormData } from "./invoice.form.schema";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convierte el DTO completo de API a datos numéricos para el formulario.
|
* 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 { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
|
||||||
|
|
||||||
import type { InvoiceSummaryFormData } from "./invoice-resume.form.schema";
|
import type { InvoiceSummaryFormData } from "../schemas/invoice-resume.form.schema";
|
||||||
import type { CustomerInvoiceSummary } from "./invoices.api.schema";
|
import type { CustomerInvoiceSummary } from "../schemas/invoices.api.schema";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convierte el DTO completo de API a datos numéricos para el formulario.
|
* 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
|
// Lazy load components
|
||||||
const InvoicesLayout = lazy(() =>
|
const InvoicesLayout = lazy(() =>
|
||||||
import("./components").then((m) => ({ default: m.InvoicesLayout }))
|
import("./shared/ui").then((m) => ({ default: m.CustomerInvoicesLayout }))
|
||||||
);
|
);
|
||||||
|
|
||||||
const ProformaListPage = lazy(() =>
|
const ProformaListPage = lazy(() =>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
|
|||||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { CreateProformaRequestSchema } from "../../common";
|
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";
|
import type { InvoiceFormData } from "../schemas";
|
||||||
|
|
||||||
type CreateCustomerInvoicePayload = {
|
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 { CustomerModalSelector } from "@erp/customers/components";
|
||||||
|
|
||||||
import { DevTool } from "@hookform/devtools";
|
import { DevTool } from "@hookform/devtools";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { TextAreaField, TextField } from "@repo/rdx-ui/components";
|
import { TextAreaField, TextField } from "@repo/rdx-ui/components";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@ -38,10 +34,14 @@ import {
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { es } from "date-fns/locale";
|
import { es } from "date-fns/locale";
|
||||||
import { CalendarIcon, PlusIcon, Save, Trash2Icon, X } from "lucide-react";
|
import { CalendarIcon, PlusIcon, Save, Trash2Icon, X } from "lucide-react";
|
||||||
import { CustomerInvoicePricesCard } from "../../components";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { CustomerInvoiceItemsCardEditor } from "../../components/items";
|
import * as z from "zod";
|
||||||
|
|
||||||
import { useTranslation } from "../../i18n";
|
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";
|
import { formatCurrency } from "./utils";
|
||||||
|
|
||||||
const invoiceFormSchema = z.object({
|
const invoiceFormSchema = z.object({
|
||||||
@ -279,159 +279,159 @@ export const CreateCustomerInvoiceEditForm = ({
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
|
className="grid grid-cols-1 md:gap-6 md:grid-cols-2"
|
||||||
onSubmit={form.handleSubmit(handleSubmit, handleError)}
|
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>
|
<CardHeader>
|
||||||
<CardTitle>Cliente</CardTitle>
|
<CardTitle>Cliente</CardTitle>
|
||||||
<CardDescription>Description</CardDescription>
|
<CardDescription>Description</CardDescription>
|
||||||
<CardAction>
|
<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>
|
</CardAction>
|
||||||
</CardHeader>
|
</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>
|
||||||
<div className='space-y-1'>
|
<div className="space-y-1">
|
||||||
<h4 className='text-sm leading-none font-medium'>Radix Primitives</h4>
|
<h4 className="text-sm leading-none font-medium">Radix Primitives</h4>
|
||||||
<p className='text-muted-foreground text-sm'>
|
<p className="text-muted-foreground text-sm">
|
||||||
An open-source UI component library.
|
An open-source UI component library.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Separator className='my-4' />
|
<Separator className="my-4" />
|
||||||
<div className='flex h-5 items-center space-x-4 text-sm'>
|
<div className="flex h-5 items-center space-x-4 text-sm">
|
||||||
<div>Blog</div>
|
<div>Blog</div>
|
||||||
<Separator orientation='vertical' />
|
<Separator orientation="vertical" />
|
||||||
<div>Docs</div>
|
<div>Docs</div>
|
||||||
<Separator orientation='vertical' />
|
<Separator orientation="vertical" />
|
||||||
<div>Source</div>
|
<div>Source</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className='flex-col gap-2'>
|
<CardFooter className="flex-col gap-2">
|
||||||
<Button type='submit' className='w-full'>
|
<Button className="w-full" type="submit">
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='outline' className='w-full'>
|
<Button className="w-full" variant="outline">
|
||||||
Login with Google
|
Login with Google
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>{" "}
|
</CardFooter>{" "}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Información básica */}
|
{/* Información básica */}
|
||||||
<Card className='border-0 shadow-none '>
|
<Card className="border-0 shadow-none ">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Información Básica</CardTitle>
|
<CardTitle>Información Básica</CardTitle>
|
||||||
<CardDescription>Detalles generales de la factura</CardDescription>
|
<CardDescription>Detalles generales de la factura</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-8'>
|
<CardContent className="space-y-8">
|
||||||
<div className='grid gap-y-6 gap-x-8 md:grid-cols-4'>
|
<div className="grid gap-y-6 gap-x-8 md:grid-cols-4">
|
||||||
<TextField
|
<TextField
|
||||||
control={form.control}
|
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")}
|
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
|
<DatePickerInputField
|
||||||
control={form.control}
|
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")}
|
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
|
<TextField
|
||||||
control={form.control}
|
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")}
|
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>
|
||||||
<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
|
<TextField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='description'
|
|
||||||
required
|
|
||||||
label={t("form_fields.description.label")}
|
|
||||||
placeholder={t("form_fields.description.placeholder")}
|
|
||||||
description={t("form_fields.description.description")}
|
description={t("form_fields.description.description")}
|
||||||
|
label={t("form_fields.description.label")}
|
||||||
|
name="description"
|
||||||
|
placeholder={t("form_fields.description.placeholder")}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<TextAreaField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='notes'
|
|
||||||
required
|
|
||||||
label={t("form_fields.notes.label")}
|
|
||||||
placeholder={t("form_fields.notes.placeholder")}
|
|
||||||
description={t("form_fields.notes.description")}
|
description={t("form_fields.notes.description")}
|
||||||
|
label={t("form_fields.notes.label")}
|
||||||
|
name="notes"
|
||||||
|
placeholder={t("form_fields.notes.placeholder")}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Cliente */}
|
{/* Cliente */}
|
||||||
<Card className='col-span-full'>
|
<Card className="col-span-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Cliente</CardTitle>
|
<CardTitle>Cliente</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='grid grid-cols-1 gap-4 space-y-6'>
|
<CardContent className="grid grid-cols-1 gap-4 space-y-6">
|
||||||
<CustomerModalSelector />
|
<CustomerModalSelector />
|
||||||
<TextField
|
<TextField
|
||||||
control={form.control}
|
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")}
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/*Items */}
|
{/*Items */}
|
||||||
<CustomerInvoiceItemsCardEditor
|
<CustomerInvoiceItemsCardEditor
|
||||||
|
className="col-span-full"
|
||||||
defaultValues={defaultInvoiceData}
|
defaultValues={defaultInvoiceData}
|
||||||
className='col-span-full'
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Items */}
|
{/* Items */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className='flex flex-row items-center justify-between'>
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Artículos</CardTitle>
|
<CardTitle>Artículos</CardTitle>
|
||||||
<CardDescription>Lista de productos o servicios facturados</CardDescription>
|
<CardDescription>Lista de productos o servicios facturados</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button type='button' onClick={addItem} size='sm'>
|
<Button onClick={addItem} size="sm" type="button">
|
||||||
<PlusIcon className='h-4 w-4 mr-2' />
|
<PlusIcon className="h-4 w-4 mr-2" />
|
||||||
Agregar Item
|
Agregar Item
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className="space-y-4">
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<Card key={field.id} className='p-4'>
|
<Card className="p-4" key={field.id}>
|
||||||
<div className='flex justify-between items-start mb-4'>
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div className='flex items-center gap-2'>
|
<div className="flex items-center gap-2">
|
||||||
<h4 className='font-medium'>Item {index + 1}</h4>
|
<h4 className="font-medium">Item {index + 1}</h4>
|
||||||
</div>
|
</div>
|
||||||
{fields.length > 1 && (
|
{fields.length > 1 && (
|
||||||
<Button type='button' variant='outline' size='sm' onClick={() => remove(index)}>
|
<Button onClick={() => remove(index)} size="sm" type="button" variant="outline">
|
||||||
<Trash2Icon className='h-4 w-4' />
|
<Trash2Icon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`items.${index}.id_article`}
|
name={`items.${index}.id_article`}
|
||||||
@ -439,7 +439,7 @@ export const CreateCustomerInvoiceEditForm = ({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Código Artículo</FormLabel>
|
<FormLabel>Código Artículo</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder='Código' {...field} />
|
<Input placeholder="Código" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -450,10 +450,10 @@ export const CreateCustomerInvoiceEditForm = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name={`items.${index}.description`}
|
name={`items.${index}.description`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className='md:col-span-2'>
|
<FormItem className="md:col-span-2">
|
||||||
<FormLabel>Descripción</FormLabel>
|
<FormLabel>Descripción</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea placeholder='Descripción del producto/servicio' {...field} />
|
<Textarea placeholder="Descripción del producto/servicio" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -462,10 +462,10 @@ export const CreateCustomerInvoiceEditForm = ({
|
|||||||
|
|
||||||
<TextAreaField
|
<TextAreaField
|
||||||
control={form.control}
|
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")}
|
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
|
<FormField
|
||||||
@ -476,9 +476,9 @@ export const CreateCustomerInvoiceEditForm = ({
|
|||||||
<FormLabel>Cantidad</FormLabel>
|
<FormLabel>Cantidad</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type='number'
|
min="0"
|
||||||
step='0.01'
|
step="0.01"
|
||||||
min='0'
|
type="number"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
|
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
|
||||||
value={field.value / 100}
|
value={field.value / 100}
|
||||||
@ -497,9 +497,9 @@ export const CreateCustomerInvoiceEditForm = ({
|
|||||||
<FormLabel>Precio Unitario</FormLabel>
|
<FormLabel>Precio Unitario</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type='number'
|
min="0"
|
||||||
step='0.01'
|
step="0.01"
|
||||||
min='0'
|
type="number"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
|
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
|
||||||
value={field.value / 100}
|
value={field.value / 100}
|
||||||
@ -518,10 +518,10 @@ export const CreateCustomerInvoiceEditForm = ({
|
|||||||
<FormLabel>Descuento (%)</FormLabel>
|
<FormLabel>Descuento (%)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type='number'
|
max="100"
|
||||||
step='0.01'
|
min="0"
|
||||||
min='0'
|
step="0.01"
|
||||||
max='100'
|
type="number"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
|
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
|
||||||
value={field.value / 100}
|
value={field.value / 100}
|
||||||
@ -533,8 +533,8 @@ export const CreateCustomerInvoiceEditForm = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-4 p-3 bg-muted rounded-lg'>
|
<div className="mt-4 p-3 bg-muted rounded-lg">
|
||||||
<div className='text-sm text-muted-foreground'>
|
<div className="text-sm text-muted-foreground">
|
||||||
Total del item:{" "}
|
Total del item:{" "}
|
||||||
{formatCurrency(
|
{formatCurrency(
|
||||||
watchedItems[index]?.total_price?.amount || 0,
|
watchedItems[index]?.total_price?.amount || 0,
|
||||||
@ -556,20 +556,20 @@ export const CreateCustomerInvoiceEditForm = ({
|
|||||||
<CardTitle>Impuestos y Totales</CardTitle>
|
<CardTitle>Impuestos y Totales</CardTitle>
|
||||||
<CardDescription>Configuración de impuestos y resumen de totales</CardDescription>
|
<CardDescription>Configuración de impuestos y resumen de totales</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className="space-y-4">
|
||||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='tax.amount'
|
name="tax.amount"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Tasa de Impuesto (%)</FormLabel>
|
<FormLabel>Tasa de Impuesto (%)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type='number'
|
max="100"
|
||||||
step='0.01'
|
min="0"
|
||||||
min='0'
|
step="0.01"
|
||||||
max='100'
|
type="number"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
|
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
|
||||||
value={field.value / 100}
|
value={field.value / 100}
|
||||||
@ -584,33 +584,33 @@ export const CreateCustomerInvoiceEditForm = ({
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Resumen de totales */}
|
{/* Resumen de totales */}
|
||||||
<div className='space-y-3'>
|
<div className="space-y-3">
|
||||||
<div className='flex justify-between text-sm'>
|
<div className="flex justify-between text-sm">
|
||||||
<span>Subtotal:</span>
|
<span>Subtotal:</span>
|
||||||
<span>
|
<span>
|
||||||
{formatCurrency(form.watch("subtotal_price.amount"), 2, form.watch("currency"))}
|
{formatCurrency(form.watch("subtotal_price.amount"), 2, form.watch("currency"))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex justify-between text-sm'>
|
<div className="flex justify-between text-sm">
|
||||||
<span>Descuento:</span>
|
<span>Descuento:</span>
|
||||||
<span>
|
<span>
|
||||||
-{formatCurrency(form.watch("discount_price.amount"), 2, form.watch("currency"))}
|
-{formatCurrency(form.watch("discount_price.amount"), 2, form.watch("currency"))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex justify-between text-sm'>
|
<div className="flex justify-between text-sm">
|
||||||
<span>Base imponible:</span>
|
<span>Base imponible:</span>
|
||||||
<span>
|
<span>
|
||||||
{formatCurrency(form.watch("before_tax_price.amount"), 2, form.watch("currency"))}
|
{formatCurrency(form.watch("before_tax_price.amount"), 2, form.watch("currency"))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>Impuestos ({(form.watch("tax.amount") / 100).toFixed(2)}%):</span>
|
||||||
<span>
|
<span>
|
||||||
{formatCurrency(form.watch("tax_price.amount"), 2, form.watch("currency"))}
|
{formatCurrency(form.watch("tax_price.amount"), 2, form.watch("currency"))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className='flex justify-between text-lg font-semibold'>
|
<div className="flex justify-between text-lg font-semibold">
|
||||||
<span>Total:</span>
|
<span>Total:</span>
|
||||||
<span>
|
<span>
|
||||||
{formatCurrency(form.watch("total_price.amount"), 2, form.watch("currency"))}
|
{formatCurrency(form.watch("total_price.amount"), 2, form.watch("currency"))}
|
||||||
@ -619,11 +619,11 @@ export const CreateCustomerInvoiceEditForm = ({
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div className='flex justify-end space-x-4'>
|
<div className="flex justify-end space-x-4">
|
||||||
<Button type='button' variant='outline' disabled={isPending} onClick={handleCancel}>
|
<Button disabled={isPending} onClick={handleCancel} type="button" variant="outline">
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button type='submit' disabled={isPending}>
|
<Button disabled={isPending} type="submit">
|
||||||
Guardar Factura
|
Guardar Factura
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -633,130 +633,130 @@ export const CreateCustomerInvoiceEditForm = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 bg-muted/50'>
|
<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'>
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
{/* Información básica */}
|
{/* Información básica */}
|
||||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<Label htmlFor='id'>ID de Factura</Label>
|
<Label htmlFor="id">ID de Factura</Label>
|
||||||
<Input id='id' value={formData.id} disabled className='bg-muted' />
|
<Input className="bg-muted" disabled id="id" value={formData.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<Label htmlFor='invoice_status'>Estado</Label>
|
<Label htmlFor="invoice_status">Estado</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.invoice_status}
|
|
||||||
onValueChange={(value) => handleInputChange("invoice_status", value)}
|
onValueChange={(value) => handleInputChange("invoice_status", value)}
|
||||||
|
value={formData.invoice_status}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value='draft'>Borrador</SelectItem>
|
<SelectItem value="draft">Borrador</SelectItem>
|
||||||
<SelectItem value='sent'>Enviada</SelectItem>
|
<SelectItem value="sent">Enviada</SelectItem>
|
||||||
<SelectItem value='paid'>Pagada</SelectItem>
|
<SelectItem value="paid">Pagada</SelectItem>
|
||||||
<SelectItem value='cancelled'>Cancelada</SelectItem>
|
<SelectItem value="cancelled">Cancelada</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<Label htmlFor='language_code'>Idioma</Label>
|
<Label htmlFor="language_code">Idioma</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.language_code}
|
|
||||||
onValueChange={(value) => handleInputChange("language_code", value)}
|
onValueChange={(value) => handleInputChange("language_code", value)}
|
||||||
|
value={formData.language_code}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value='ES'>Español</SelectItem>
|
<SelectItem value="ES">Español</SelectItem>
|
||||||
<SelectItem value='EN'>English</SelectItem>
|
<SelectItem value="EN">English</SelectItem>
|
||||||
<SelectItem value='FR'>Français</SelectItem>
|
<SelectItem value="FR">Français</SelectItem>
|
||||||
<SelectItem value='DE'>Deutsch</SelectItem>
|
<SelectItem value="DE">Deutsch</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Numeración */}
|
{/* Numeración */}
|
||||||
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<Label htmlFor='invoice_series'>Serie</Label>
|
<Label htmlFor="invoice_series">Serie</Label>
|
||||||
<Input
|
<Input
|
||||||
id='invoice_series'
|
id="invoice_series"
|
||||||
value={formData.invoice_series}
|
|
||||||
onChange={(e) => handleInputChange("invoice_series", e.target.value)}
|
onChange={(e) => handleInputChange("invoice_series", e.target.value)}
|
||||||
placeholder='A'
|
placeholder="A"
|
||||||
|
value={formData.invoice_series}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<Label htmlFor='invoice_number'>Número</Label>
|
<Label htmlFor="invoice_number">Número</Label>
|
||||||
<Input
|
<Input
|
||||||
id='invoice_number'
|
id="invoice_number"
|
||||||
value={formData.invoice_number}
|
|
||||||
onChange={(e) => handleInputChange("invoice_number", e.target.value)}
|
onChange={(e) => handleInputChange("invoice_number", e.target.value)}
|
||||||
placeholder='1'
|
placeholder="1"
|
||||||
|
value={formData.invoice_number}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-2 hidden'>
|
<div className="space-y-2 hidden">
|
||||||
<Label htmlFor='currency'>Moneda</Label>
|
<Label htmlFor="currency">Moneda</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.currency}
|
|
||||||
onValueChange={(value) => handleInputChange("currency", value)}
|
onValueChange={(value) => handleInputChange("currency", value)}
|
||||||
|
value={formData.currency}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value='EUR'>EUR (€)</SelectItem>
|
<SelectItem value="EUR">EUR (€)</SelectItem>
|
||||||
<SelectItem value='USD'>USD ($)</SelectItem>
|
<SelectItem value="USD">USD ($)</SelectItem>
|
||||||
<SelectItem value='GBP'>GBP (£)</SelectItem>
|
<SelectItem value="GBP">GBP (£)</SelectItem>
|
||||||
<SelectItem value='JPY'>JPY (¥)</SelectItem>
|
<SelectItem value="JPY">JPY (¥)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fechas */}
|
{/* Fechas */}
|
||||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<Label>Fecha de Emisión</Label>
|
<Label>Fecha de Emisión</Label>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant='outline' className='w-full justify-start text-left font-normal'>
|
<Button className="w-full justify-start text-left font-normal" variant="outline">
|
||||||
<CalendarIcon className='mr-2 h-4 w-4' />
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
{format(invoiceDate, "PPP", { locale: es })}
|
{format(invoiceDate, "PPP", { locale: es })}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className='w-auto p-0' align='start'>
|
<PopoverContent align="start" className="w-auto p-0">
|
||||||
<Calendar
|
<Calendar
|
||||||
mode='single'
|
|
||||||
selected={invoiceDate}
|
|
||||||
onSelect={(date) => handleDateChange("invoice_date", date)}
|
|
||||||
initialFocus
|
initialFocus
|
||||||
|
mode="single"
|
||||||
|
onSelect={(date) => handleDateChange("invoice_date", date)}
|
||||||
|
selected={invoiceDate}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<Label>Fecha de Operación</Label>
|
<Label>Fecha de Operación</Label>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant='outline' className='w-full justify-start text-left font-normal'>
|
<Button className="w-full justify-start text-left font-normal" variant="outline">
|
||||||
<CalendarIcon className='mr-2 h-4 w-4' />
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
{format(operationDate, "PPP", { locale: es })}
|
{format(operationDate, "PPP", { locale: es })}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className='w-auto p-0' align='start'>
|
<PopoverContent align="start" className="w-auto p-0">
|
||||||
<Calendar
|
<Calendar
|
||||||
mode='single'
|
|
||||||
selected={operationDate}
|
|
||||||
onSelect={(date) => handleDateChange("operation_date", date)}
|
|
||||||
initialFocus
|
initialFocus
|
||||||
|
mode="single"
|
||||||
|
onSelect={(date) => handleDateChange("operation_date", date)}
|
||||||
|
selected={operationDate}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@ -764,46 +764,46 @@ export const CreateCustomerInvoiceEditForm = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Importes */}
|
{/* Importes */}
|
||||||
<div className='space-y-4'>
|
<div className="space-y-4">
|
||||||
<h3 className='text-lg font-semibold'>Importes</h3>
|
<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>
|
<Card>
|
||||||
<CardHeader className='pb-3'>
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className='text-base'>Subtotal</CardTitle>
|
<CardTitle className="text-base">Subtotal</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-3'>
|
<CardContent className="space-y-3">
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<Label htmlFor='subtotal_amount'>Importe</Label>
|
<Label htmlFor="subtotal_amount">Importe</Label>
|
||||||
<Input
|
<Input
|
||||||
id='subtotal_amount'
|
id="subtotal_amount"
|
||||||
type='number'
|
|
||||||
step='0.01'
|
|
||||||
value={formData.subtotal.amount / Math.pow(10, formData.subtotal.scale)}
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleNestedChange(
|
handleNestedChange(
|
||||||
"subtotal",
|
"subtotal",
|
||||||
"amount",
|
"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>
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<Label htmlFor='subtotal_currency'>Moneda</Label>
|
<Label htmlFor="subtotal_currency">Moneda</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.subtotal.currency_code}
|
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleNestedChange("subtotal", "currency_code", value)
|
handleNestedChange("subtotal", "currency_code", value)
|
||||||
}
|
}
|
||||||
|
value={formData.subtotal.currency_code}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value='EUR'>EUR</SelectItem>
|
<SelectItem value="EUR">EUR</SelectItem>
|
||||||
<SelectItem value='USD'>USD</SelectItem>
|
<SelectItem value="USD">USD</SelectItem>
|
||||||
<SelectItem value='GBP'>GBP</SelectItem>
|
<SelectItem value="GBP">GBP</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@ -811,39 +811,39 @@ export const CreateCustomerInvoiceEditForm = ({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className='pb-3'>
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className='text-base'>Total</CardTitle>
|
<CardTitle className="text-base">Total</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-3'>
|
<CardContent className="space-y-3">
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<Label htmlFor='total_amount'>Importe</Label>
|
<Label htmlFor="total_amount">Importe</Label>
|
||||||
<Input
|
<Input
|
||||||
id='total_amount'
|
id="total_amount"
|
||||||
type='number'
|
|
||||||
step='0.01'
|
|
||||||
value={formData.total.amount / Math.pow(10, formData.total.scale)}
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleNestedChange(
|
handleNestedChange(
|
||||||
"total",
|
"total",
|
||||||
"amount",
|
"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>
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<Label htmlFor='total_currency'>Moneda</Label>
|
<Label htmlFor="total_currency">Moneda</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.total.currency_code}
|
|
||||||
onValueChange={(value) => handleNestedChange("total", "currency_code", value)}
|
onValueChange={(value) => handleNestedChange("total", "currency_code", value)}
|
||||||
|
value={formData.total.currency_code}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value='EUR'>EUR</SelectItem>
|
<SelectItem value="EUR">EUR</SelectItem>
|
||||||
<SelectItem value='USD'>USD</SelectItem>
|
<SelectItem value="USD">USD</SelectItem>
|
||||||
<SelectItem value='GBP'>GBP</SelectItem>
|
<SelectItem value="GBP">GBP</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@ -853,18 +853,18 @@ export const CreateCustomerInvoiceEditForm = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Botones de acción */}
|
{/* 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
|
<Button
|
||||||
type='button'
|
className="flex items-center gap-2"
|
||||||
variant='outline'
|
|
||||||
onClick={handleCancel}
|
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
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button type='submit' className='flex items-center gap-2'>
|
<Button className="flex items-center gap-2" type="submit">
|
||||||
<Save className='h-4 w-4' />
|
<Save className="h-4 w-4" />
|
||||||
Guardar Cambios
|
Guardar Cambios
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,9 +6,9 @@ import { PlusIcon } from "lucide-react";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { invoiceResumeDtoToFormAdapter } from "../../adapters/invoice-resume-dto.adapter";
|
||||||
import { useInvoicesQuery } from "../../hooks";
|
import { useInvoicesQuery } from "../../hooks";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { invoiceResumeDtoToFormAdapter } from "../../schemas/invoice-resume-dto.adapter";
|
|
||||||
|
|
||||||
import { InvoicesListGrid } from "./invoices-list-grid";
|
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 type { InvoiceFormData } from "../../schemas";
|
||||||
import { cn } from '@repo/shadcn-ui/lib/utils';
|
import {
|
||||||
import { InvoiceBasicInfoFields, InvoiceItems, InvoiceRecipient, InvoiceTotals } from '../../components';
|
InvoiceBasicInfoFields,
|
||||||
import { InvoiceFormData } from "../../schemas";
|
InvoiceItems,
|
||||||
|
InvoiceRecipient,
|
||||||
|
InvoiceTotals,
|
||||||
|
} from "../../shared/ui/components";
|
||||||
|
|
||||||
interface InvoiceUpdateFormProps {
|
interface InvoiceUpdateFormProps {
|
||||||
formId: string;
|
formId: string;
|
||||||
@ -21,11 +26,14 @@ export const InvoiceUpdateForm = ({
|
|||||||
const form = useFormContext<InvoiceFormData>();
|
const form = useFormContext<InvoiceFormData>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form noValidate id={formId} onSubmit={
|
<form
|
||||||
(event: React.FormEvent<HTMLFormElement>) => {
|
id={formId}
|
||||||
|
noValidate
|
||||||
|
onSubmit={(event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
form.handleSubmit(onSubmit, onError)(event)
|
form.handleSubmit(onSubmit, onError)(event);
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<FormDebug />
|
<FormDebug />
|
||||||
|
|
||||||
<section className={cn("space-y-6 p-6", className)}>
|
<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" />
|
<InvoiceBasicInfoFields className="flex flex-col lg:col-span-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='w-full'>
|
<div className="w-full">
|
||||||
<InvoiceItems />
|
<InvoiceItems />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full grid grid-cols-1 lg:grid-cols-2">
|
<div className="w-full grid grid-cols-1 lg:grid-cols-2">
|
||||||
<InvoiceTotals className='lg:col-start-2' />
|
<InvoiceTotals className="lg:col-start-2" />
|
||||||
</div>
|
|
||||||
<div className="w-full">
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-full"></div>
|
||||||
</section>
|
</section>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
import { SpainTaxCatalogProvider } from '@erp/core';
|
import { SpainTaxCatalogProvider } from "@erp/core";
|
||||||
import {
|
import { useUrlParamId } from "@erp/core/hooks";
|
||||||
useUrlParamId
|
|
||||||
} from "@erp/core/hooks";
|
|
||||||
import { ErrorAlert } from "@erp/customers/components";
|
import { ErrorAlert } from "@erp/customers/components";
|
||||||
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
|
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
import {
|
|
||||||
CustomerInvoiceEditorSkeleton
|
import { InvoiceProvider } from "../../context";
|
||||||
} from "../../components";
|
|
||||||
import { InvoiceProvider } from '../../context';
|
|
||||||
import { useInvoiceQuery } from "../../hooks";
|
import { useInvoiceQuery } from "../../hooks";
|
||||||
import { useTranslation } from "../../i18n";
|
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 = () => {
|
export const InvoiceUpdatePage = () => {
|
||||||
const invoice_id = useUrlParamId();
|
const invoice_id = useUrlParamId();
|
||||||
@ -29,8 +27,8 @@ export const InvoiceUpdatePage = () => {
|
|||||||
return (
|
return (
|
||||||
<AppContent>
|
<AppContent>
|
||||||
<ErrorAlert
|
<ErrorAlert
|
||||||
title={t("pages.update.loadErrorTitle")}
|
|
||||||
message={(error as Error)?.message || "Error al cargar la factura"}
|
message={(error as Error)?.message || "Error al cargar la factura"}
|
||||||
|
title={t("pages.update.loadErrorTitle")}
|
||||||
/>
|
/>
|
||||||
<BackHistoryButton />
|
<BackHistoryButton />
|
||||||
</AppContent>
|
</AppContent>
|
||||||
@ -40,15 +38,14 @@ export const InvoiceUpdatePage = () => {
|
|||||||
// Monta el contexto aquí, así todo lo que esté dentro puede usar hooks
|
// Monta el contexto aquí, así todo lo que esté dentro puede usar hooks
|
||||||
return (
|
return (
|
||||||
<InvoiceProvider
|
<InvoiceProvider
|
||||||
invoice_id={invoice_id!}
|
|
||||||
taxCatalog={taxCatalog}
|
|
||||||
company_id={invoiceData.company_id}
|
company_id={invoiceData.company_id}
|
||||||
status={invoiceData.status}
|
|
||||||
language_code={invoiceData.language_code}
|
|
||||||
currency_code={invoiceData.currency_code}
|
currency_code={invoiceData.currency_code}
|
||||||
|
invoice_id={invoice_id!}
|
||||||
|
language_code={invoiceData.language_code}
|
||||||
|
status={invoiceData.status}
|
||||||
|
taxCatalog={taxCatalog}
|
||||||
>
|
>
|
||||||
<InvoiceUpdateComp invoice={invoiceData} />
|
<InvoiceUpdateComp invoice={invoiceData} />
|
||||||
</InvoiceProvider>
|
</InvoiceProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./proforma-summary-dto.adapter";
|
||||||
@ -1,12 +1,15 @@
|
|||||||
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
|
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
|
||||||
|
|
||||||
import type { ProformaSummaryPage } from "./proforma.api.schema";
|
import type { ProformaSummaryPage } from "../schema/proforma.api.schema";
|
||||||
import type { ProformaSummaryData, ProformaSummaryPageData } from "./proforma-resume.form.schema";
|
import type {
|
||||||
|
ProformaSummaryData,
|
||||||
|
ProformaSummaryPageData,
|
||||||
|
} from "../schema/proforma-summary.web.schema";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convierte el DTO completo de API a datos numéricos para el formulario.
|
* 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 {
|
fromDto(pageDto: ProformaSummaryPage, context?: unknown): ProformaSummaryPageData {
|
||||||
return {
|
return {
|
||||||
...pageDto,
|
...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 { useDataSource } from "@erp/core/hooks";
|
||||||
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
|
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;
|
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 { useDataSource } from "@erp/core/hooks";
|
||||||
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
|
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 => [
|
export const PROFORMAS_QUERY_KEY = (criteria: CriteriaDTO): QueryKey => [
|
||||||
"proforma",
|
"proforma",
|
||||||
|
|||||||
@ -1,2 +1 @@
|
|||||||
export * from "./hooks";
|
|
||||||
export * from "./pages";
|
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 { PageHeader } from "@erp/core/components";
|
||||||
import { ErrorAlert } from "@erp/customers/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 { Button } from "@repo/shadcn-ui/components";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
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 = () => {
|
export const ProformaListPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const list = useProformasList();
|
||||||
|
|
||||||
const [pageIndex, setPageIndex] = useState(0);
|
if (list.isError || !list.data) {
|
||||||
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) {
|
|
||||||
return (
|
return (
|
||||||
<AppContent>
|
<AppContent>
|
||||||
<ErrorAlert
|
<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")}
|
title={t("pages.proformas.list.loadErrorTitle")}
|
||||||
/>
|
/>
|
||||||
<BackHistoryButton />
|
<BackHistoryButton />
|
||||||
@ -82,36 +33,28 @@ export const ProformaListPage = () => {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
description={t("pages.proformas.list.description")}
|
description={t("pages.proformas.list.description")}
|
||||||
rightSlot={
|
rightSlot={
|
||||||
<div className="flex items-center space-x-2">
|
<Button
|
||||||
<Button
|
aria-label={t("pages.proformas.create.title")}
|
||||||
aria-label={t("pages.proformas.create.title")}
|
onClick={() => navigate("/proformas/create")}
|
||||||
className="cursor-pointer"
|
>
|
||||||
onClick={() => navigate("/proformas/create")}
|
<PlusIcon aria-hidden className="mr-2 size-4" />
|
||||||
variant={"default"}
|
{t("pages.proformas.create.title")}
|
||||||
>
|
</Button>
|
||||||
<PlusIcon aria-hidden className="mr-2 h-4 w-4" />
|
|
||||||
{t("pages.proformas.create.title")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
title={t("pages.proformas.list.title")}
|
title={t("pages.proformas.list.title")}
|
||||||
/>
|
/>
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
<AppContent>
|
<AppContent>
|
||||||
<div className="flex flex-col w-full h-full py-3">
|
<ProformasGrid
|
||||||
<div className={"flex-1"}>
|
data={list.data}
|
||||||
<ProformasGrid
|
loading={list.isLoading}
|
||||||
data={proformaPageData}
|
onPageChange={list.setPageIndex}
|
||||||
loading={isLoading}
|
onPageSizeChange={list.setPageSize}
|
||||||
onPageChange={handlePageChange}
|
onSearchChange={list.setSearchValue}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
pageIndex={list.pageIndex}
|
||||||
onSearchChange={handleSearchChange}
|
pageSize={list.pageSize}
|
||||||
pageIndex={pageIndex}
|
searchValue={list.search}
|
||||||
pageSize={pageSize}
|
/>
|
||||||
searchValue={search}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AppContent>
|
</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 { usePinnedPreviewSheet } from "../../../hooks";
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
import type { InvoiceSummaryFormData } from "../../../schemas";
|
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";
|
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";
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { CustomerInvoiceStatusBadge } from "../../../components";
|
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
import type { InvoiceSummaryFormData } from "../../../schemas";
|
import type { InvoiceSummaryFormData } from "../../../schemas";
|
||||||
|
import { CustomerInvoiceStatusBadge } from "../../../shared/ui/components";
|
||||||
|
|
||||||
type GridActionHandlers = {
|
type GridActionHandlers = {
|
||||||
onEdit?: (invoice: InvoiceSummaryFormData) => void;
|
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.form.schema";
|
||||||
export * from "./invoice-dto.adapter";
|
|
||||||
export * from "./invoice-resume.form.schema";
|
export * from "./invoice-resume.form.schema";
|
||||||
export * from "./invoice-resume-dto.adapter";
|
|
||||||
export * from "./invoices.api.schema";
|
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 { Button } from "@repo/shadcn-ui/components";
|
||||||
import { PlusCircleIcon } from "lucide-react";
|
import { PlusCircleIcon } from "lucide-react";
|
||||||
import { JSX, forwardRef } from "react";
|
import { type JSX, forwardRef } from "react";
|
||||||
import { useTranslation } from "../../i18n";
|
|
||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
|
||||||
export interface AppendEmptyRowButtonProps extends React.ComponentProps<typeof Button> {
|
export interface AppendEmptyRowButtonProps extends React.ComponentProps<typeof Button> {
|
||||||
label?: string;
|
label?: string;
|
||||||
@ -14,7 +15,7 @@ export const AppendEmptyRowButton = forwardRef<HTMLButtonElement, AppendEmptyRow
|
|||||||
const _label = label || t("common.append_empty_row");
|
const _label = label || t("common.append_empty_row");
|
||||||
|
|
||||||
return (
|
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"} />
|
<PlusCircleIcon className={_label ? "w-4 h-4 mr-2" : "w-4 h-4"} />
|
||||||
{_label && <>{_label}</>}
|
{_label && <>{_label}</>}
|
||||||
</Button>
|
</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,
|
Separator,
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import { useFormContext } from "react-hook-form";
|
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 = () => {
|
export const CustomerInvoicePricesCard = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -32,52 +33,52 @@ export const CustomerInvoicePricesCard = () => {
|
|||||||
<CardDescription>Configuración de impuestos y resumen de totales</CardDescription>
|
<CardDescription>Configuración de impuestos y resumen de totales</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className='flex flex-row items-end gap-2 p-4'>
|
<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 flex-1 h-16 grid-cols-1 auto-rows-max">
|
||||||
<div className='grid gap-1 font-semibold text-right text-muted-foreground'>
|
<div className="grid gap-1 font-semibold text-right text-muted-foreground">
|
||||||
<CardDescription className='text-sm'>
|
<CardDescription className="text-sm">
|
||||||
{t("form_fields.subtotal_price.label")}
|
{t("form_fields.subtotal_price.label")}
|
||||||
</CardDescription>
|
</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"))}
|
{formatCurrency(watch("subtotal_price.amount"), 2, watch("currency"))}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator orientation='vertical' className='w-px h-16 mx-2' />
|
<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 flex-1 h-16 grid-cols-2 gap-6 auto-rows-max">
|
||||||
<div className='grid gap-1 font-medium text-muted-foreground'>
|
<div className="grid gap-1 font-medium text-muted-foreground">
|
||||||
<CardDescription className='text-sm'>{t("form_fields.discount.label")}</CardDescription>
|
<CardDescription className="text-sm">{t("form_fields.discount.label")}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-1 font-semibold text-muted-foreground'>
|
<div className="grid gap-1 font-semibold text-muted-foreground">
|
||||||
<CardDescription className='text-sm text-right'>
|
<CardDescription className="text-sm text-right">
|
||||||
{t("form_fields.discount_price.label")}
|
{t("form_fields.discount_price.label")}
|
||||||
</CardDescription>
|
</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"))}
|
{"-"} {formatCurrency(watch("discount_price.amount"), 2, watch("currency"))}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator orientation='vertical' className='w-px h-16 mx-2' />
|
<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 flex-1 h-16 grid-cols-2 gap-6 auto-rows-max">
|
||||||
<div className='grid gap-1 font-medium text-muted-foreground'>
|
<div className="grid gap-1 font-medium text-muted-foreground">
|
||||||
<CardDescription className='text-sm'>{t("form_fields.tax.label")}</CardDescription>
|
<CardDescription className="text-sm">{t("form_fields.tax.label")}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-1 font-semibold text-muted-foreground'>
|
<div className="grid gap-1 font-semibold text-muted-foreground">
|
||||||
<CardDescription className='text-sm text-right'>
|
<CardDescription className="text-sm text-right">
|
||||||
{t("form_fields.tax_price.label")}
|
{t("form_fields.tax_price.label")}
|
||||||
</CardDescription>
|
</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"))}
|
{formatCurrency(watch("tax_price.amount"), 2, watch("currency"))}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</div>{" "}
|
</div>{" "}
|
||||||
<Separator orientation='vertical' className='w-px h-16 mx-2' />
|
<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 flex-1 h-16 grid-cols-1 auto-rows-max">
|
||||||
<div className='grid gap-0'>
|
<div className="grid gap-0">
|
||||||
<CardDescription className='text-sm font-semibold text-right text-foreground'>
|
<CardDescription className="text-sm font-semibold text-right text-foreground">
|
||||||
{t("form_fields.total_price.label")}
|
{t("form_fields.total_price.label")}
|
||||||
</CardDescription>
|
</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"))}
|
{formatCurrency(watch("total_price.amount"), 2, watch("currency"))}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import { Badge } from "@repo/shadcn-ui/components";
|
import { Badge } from "@repo/shadcn-ui/components";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import { forwardRef } from "react";
|
import { forwardRef } from "react";
|
||||||
import { useTranslation } from "../i18n";
|
|
||||||
|
import { useTranslation } from "../../../i18n";
|
||||||
|
|
||||||
export type CustomerInvoiceStatus = "draft" | "sent" | "approved" | "rejected" | "issued";
|
export type CustomerInvoiceStatus = "draft" | "sent" | "approved" | "rejected" | "issued";
|
||||||
|
|
||||||
@ -28,8 +29,7 @@ const statusColorConfig: Record<CustomerInvoiceStatus, { badge: string; dot: str
|
|||||||
dot: "bg-emerald-500",
|
dot: "bg-emerald-500",
|
||||||
},
|
},
|
||||||
rejected: {
|
rejected: {
|
||||||
badge:
|
badge: "bg-red-500/10 dark:bg-red-500/20 hover:bg-red-500/10 text-red-500 border-red-600/60",
|
||||||
"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",
|
dot: "bg-red-500",
|
||||||
},
|
},
|
||||||
issued: {
|
issued: {
|
||||||
@ -50,7 +50,7 @@ export const CustomerInvoiceStatusBadge = forwardRef<
|
|||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return (
|
return (
|
||||||
<Badge ref={ref} className={cn(commonClassName, className)} {...props}>
|
<Badge className={cn(commonClassName, className)} ref={ref} {...props}>
|
||||||
{status}
|
{status}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { SpainTaxCatalogProvider } from '@erp/core';
|
import { SpainTaxCatalogProvider } from "@erp/core";
|
||||||
import { MultiSelect } from "@repo/rdx-ui/components";
|
import { MultiSelect } from "@repo/rdx-ui/components";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "../i18n";
|
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../i18n";
|
||||||
|
|
||||||
interface CustomerInvoiceTaxesMultiSelect {
|
interface CustomerInvoiceTaxesMultiSelect {
|
||||||
value?: string[];
|
value?: string[];
|
||||||
@ -24,37 +24,39 @@ export const CustomerInvoiceTaxesMultiSelect = (props: CustomerInvoiceTaxesMulti
|
|||||||
* Filtra para mantener solo un elemento por grupo.
|
* Filtra para mantener solo un elemento por grupo.
|
||||||
* Si hay duplicados dentro del mismo grupo, se queda con el último.
|
* Si hay duplicados dentro del mismo grupo, se queda con el último.
|
||||||
*/
|
*/
|
||||||
const filterSelectedByGroup = useCallback((selectedValues: string[]) => {
|
const filterSelectedByGroup = useCallback(
|
||||||
const groupMap = new Map<string | undefined, string>();
|
(selectedValues: string[]) => {
|
||||||
|
const groupMap = new Map<string | undefined, string>();
|
||||||
|
|
||||||
selectedValues.forEach((code) => {
|
selectedValues.forEach((code) => {
|
||||||
const item = taxCatalog.findByCode(code).getOrUndefined();
|
const item = taxCatalog.findByCode(code).getOrUndefined();
|
||||||
const group = item?.group ?? "ungrouped";
|
const group = item?.group ?? "ungrouped";
|
||||||
groupMap.set(group, code); // Sobrescribe el anterior del mismo grupo
|
groupMap.set(group, code); // Sobrescribe el anterior del mismo grupo
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(groupMap.values());
|
return Array.from(groupMap.values());
|
||||||
}, [taxCatalog]);
|
},
|
||||||
|
[taxCatalog]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full", "max-w-md")}>
|
<div className={cn("w-full", "max-w-md")}>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
id={inputId}
|
|
||||||
options={catalogLookup}
|
|
||||||
onValueChange={onChange}
|
|
||||||
defaultValue={value}
|
|
||||||
placeholder={t("components.customer_invoice_taxes_multi_select.placeholder")}
|
|
||||||
variant='secondary'
|
|
||||||
animation={0}
|
animation={0}
|
||||||
maxCount={3}
|
|
||||||
autoFilter={true}
|
autoFilter={true}
|
||||||
filterSelected={filterSelectedByGroup}
|
|
||||||
className={cn(
|
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",
|
"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]",
|
"hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
|
||||||
className
|
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}
|
{...otherProps}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -1,12 +1,10 @@
|
|||||||
import {
|
import { DatePickerInputField, TextField } from "@repo/rdx-ui/components";
|
||||||
DatePickerInputField,
|
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from "@repo/shadcn-ui/components";
|
||||||
TextField
|
import type { ComponentProps } from "react";
|
||||||
} from "@repo/rdx-ui/components";
|
|
||||||
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
|
|
||||||
import { ComponentProps } from 'react';
|
|
||||||
import { useFormContext } from "react-hook-form";
|
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">) => {
|
export const InvoiceBasicInfoFields = (props: ComponentProps<"fieldset">) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -14,62 +12,64 @@ export const InvoiceBasicInfoFields = (props: ComponentProps<"fieldset">) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldSet {...props}>
|
<FieldSet {...props}>
|
||||||
<FieldLegend className='hidden text-foreground' variant='label'>
|
<FieldLegend className="hidden text-foreground" variant="label">
|
||||||
{t("form_groups.basic_info.title")}
|
{t("form_groups.basic_info.title")}
|
||||||
</FieldLegend>
|
</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
|
<DatePickerInputField
|
||||||
className='min-w-44 flex-1 sm:max-w-44'
|
className="min-w-44 flex-1 sm:max-w-44"
|
||||||
control={control}
|
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")}
|
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
|
<DatePickerInputField
|
||||||
className='min-w-44 flex-1 sm:max-w-44'
|
className="min-w-44 flex-1 sm:max-w-44"
|
||||||
control={control}
|
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")}
|
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
|
<TextField
|
||||||
className='min-w-16 flex-1 sm:max-w-16'
|
className="min-w-16 flex-1 sm:max-w-16"
|
||||||
control={control}
|
control={control}
|
||||||
name='series'
|
|
||||||
label={t("form_fields.series.label")}
|
|
||||||
placeholder={t("form_fields.series.placeholder")}
|
|
||||||
description={t("form_fields.series.description")}
|
description={t("form_fields.series.description")}
|
||||||
|
label={t("form_fields.series.label")}
|
||||||
|
name="series"
|
||||||
|
placeholder={t("form_fields.series.placeholder")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
className='min-w-32 flex-1 sm:max-w-44'
|
className="min-w-32 flex-1 sm:max-w-44"
|
||||||
maxLength={256}
|
|
||||||
control={control}
|
control={control}
|
||||||
name='reference'
|
|
||||||
label={t("form_fields.reference.label")}
|
|
||||||
placeholder={t("form_fields.reference.placeholder")}
|
|
||||||
description={t("form_fields.reference.description")}
|
description={t("form_fields.reference.description")}
|
||||||
|
label={t("form_fields.reference.label")}
|
||||||
|
maxLength={256}
|
||||||
|
name="reference"
|
||||||
|
placeholder={t("form_fields.reference.placeholder")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
className='min-w-32 flex-1 xs:max-w-full'
|
className="min-w-32 flex-1 xs:max-w-full"
|
||||||
maxLength={256}
|
|
||||||
control={control}
|
control={control}
|
||||||
name='description'
|
|
||||||
label={t("form_fields.description.label")}
|
|
||||||
placeholder={t("form_fields.description.placeholder")}
|
|
||||||
description={t("form_fields.description.description")}
|
description={t("form_fields.description.description")}
|
||||||
|
label={t("form_fields.description.label")}
|
||||||
|
maxLength={256}
|
||||||
|
name="description"
|
||||||
|
placeholder={t("form_fields.description.placeholder")}
|
||||||
/>
|
/>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</FieldSet>
|
</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";
|
import { ItemsEditor } from "./items";
|
||||||
|
|
||||||
|
|
||||||
export const InvoiceItems = (props: ComponentProps<"fieldset">) => {
|
export const InvoiceItems = (props: ComponentProps<"fieldset">) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldSet {...props}>
|
<FieldSet {...props}>
|
||||||
<FieldLegend className='hidden text-foreground' variant='label'>
|
<FieldLegend className="hidden text-foreground" variant="label">
|
||||||
{t('form_groups.items.title')}
|
{t("form_groups.items.title")}
|
||||||
</FieldLegend>
|
</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 />
|
<ItemsEditor />
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -1,10 +1,11 @@
|
|||||||
import { TextAreaField } from "@repo/rdx-ui/components";
|
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 { StickyNoteIcon } from "lucide-react";
|
||||||
import { ComponentProps } from 'react';
|
import type { ComponentProps } from "react";
|
||||||
import { useFormContext } from "react-hook-form";
|
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">) => {
|
export const InvoiceNotes = (props: ComponentProps<"fieldset">) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -13,19 +14,20 @@ export const InvoiceNotes = (props: ComponentProps<"fieldset">) => {
|
|||||||
return (
|
return (
|
||||||
<FieldSet {...props}>
|
<FieldSet {...props}>
|
||||||
<FieldLegend>
|
<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>
|
</FieldLegend>
|
||||||
|
|
||||||
<FieldDescription>{t("form_groups.basic_info.description")}</FieldDescription>
|
<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
|
<TextAreaField
|
||||||
maxLength={1024}
|
className="lg:col-span-full h-full"
|
||||||
className='lg:col-span-full h-full'
|
|
||||||
control={control}
|
control={control}
|
||||||
name='notes'
|
|
||||||
label={t("form_fields.notes.label")}
|
|
||||||
placeholder={t("form_fields.notes.placeholder")}
|
|
||||||
description={t("form_fields.notes.description")}
|
description={t("form_fields.notes.description")}
|
||||||
|
label={t("form_fields.notes.label")}
|
||||||
|
maxLength={1024}
|
||||||
|
name="notes"
|
||||||
|
placeholder={t("form_fields.notes.placeholder")}
|
||||||
/>
|
/>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</FieldSet>
|
</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";
|
} from "@repo/shadcn-ui/components";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import { ReceiptIcon } from "lucide-react";
|
import { ReceiptIcon } from "lucide-react";
|
||||||
import { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import { useFormContext, useWatch } from "react-hook-form";
|
import { useFormContext, useWatch } from "react-hook-form";
|
||||||
import { useInvoiceContext } from "../../context";
|
|
||||||
import { useTranslation } from "../../i18n";
|
import { useInvoiceContext } from "../../../../context";
|
||||||
import { InvoiceFormData } from "../../schemas";
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import type { InvoiceFormData } from "../../../../schemas";
|
||||||
|
|
||||||
import { PercentageInputField } from "./items/percentage-input-field";
|
import { PercentageInputField } from "./items/percentage-input-field";
|
||||||
|
|
||||||
export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
||||||
@ -34,55 +36,55 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldSet {...props}>
|
<FieldSet {...props}>
|
||||||
<FieldLegend className='hidden'>
|
<FieldLegend className="hidden">
|
||||||
<ReceiptIcon className='size-6 text-muted-foreground' />
|
<ReceiptIcon className="size-6 text-muted-foreground" />
|
||||||
{t("form_groups.totals.title")}
|
{t("form_groups.totals.title")}
|
||||||
</FieldLegend>
|
</FieldLegend>
|
||||||
|
|
||||||
<FieldDescription className='hidden'>{t("form_groups.totals.description")}</FieldDescription>
|
<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'>
|
<FieldGroup className="grid grid-cols-1 border rounded-lg bg-muted/10 p-4 gap-4">
|
||||||
<div className='space-y-1.5'>
|
<div className="space-y-1.5">
|
||||||
{/* Sección: Subtotal y Descuentos */}
|
{/* Sección: Subtotal y Descuentos */}
|
||||||
<div className='flex justify-between text-sm'>
|
<div className="flex justify-between text-sm">
|
||||||
<span className='text-muted-foreground'>Subtotal sin descuentos</span>
|
<span className="text-muted-foreground">Subtotal sin descuentos</span>
|
||||||
<span className='font-medium tabular-nums text-muted-foreground'>
|
<span className="font-medium tabular-nums text-muted-foreground">
|
||||||
{formatCurrency(subtotal_amount, 2, currency_code, language_code)}
|
{formatCurrency(subtotal_amount, 2, currency_code, language_code)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex justify-between text-sm'>
|
<div className="flex justify-between text-sm">
|
||||||
<div className='flex items-center gap-3'>
|
<div className="flex items-center gap-3">
|
||||||
<span className='text-muted-foreground'>Descuento en líneas</span>
|
<span className="text-muted-foreground">Descuento en líneas</span>
|
||||||
</div>
|
</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)}
|
-{formatCurrency(items_discount_amount, 2, currency_code, language_code)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex justify-between text-sm'>
|
<div className="flex justify-between text-sm">
|
||||||
<div className='flex items-center gap-3'>
|
<div className="flex items-center gap-3">
|
||||||
<span className='text-muted-foreground'>Descuento global</span>
|
<span className="text-muted-foreground">Descuento global</span>
|
||||||
<PercentageInputField
|
<PercentageInputField
|
||||||
control={control}
|
|
||||||
name={"discount_percentage"}
|
|
||||||
readOnly={readOnly}
|
|
||||||
inputId={"discount-percentage"}
|
|
||||||
showSuffix={true}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-20 text-right tabular-nums bg-background",
|
"w-20 text-right tabular-nums bg-background",
|
||||||
"border-input border text-sm shadow-xs"
|
"border-input border text-sm shadow-xs"
|
||||||
)}
|
)}
|
||||||
|
control={control}
|
||||||
|
inputId={"discount-percentage"}
|
||||||
|
name={"discount_percentage"}
|
||||||
|
readOnly={readOnly}
|
||||||
|
showSuffix={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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)}
|
-{formatCurrency(discount_amount, 2, currency_code, language_code)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sección: Base Imponible */}
|
{/* Sección: Base Imponible */}
|
||||||
<div className='flex justify-between text-sm'>
|
<div className="flex justify-between text-sm">
|
||||||
<span className='text-foreground'>Base imponible</span>
|
<span className="text-foreground">Base imponible</span>
|
||||||
<span className='font-medium tabular-nums'>
|
<span className="font-medium tabular-nums">
|
||||||
{formatCurrency(taxable_amount, 2, currency_code, language_code)}
|
{formatCurrency(taxable_amount, 2, currency_code, language_code)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -91,8 +93,8 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Sección: Impuestos */}
|
{/* Sección: Impuestos */}
|
||||||
<div className='space-y-1.5'>
|
<div className="space-y-1.5">
|
||||||
<h3 className='text-xs font-semibold text-muted-foreground uppercase tracking-wide'>
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
Impuestos y retenciones
|
Impuestos y retenciones
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@ -110,7 +112,7 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
if (taxesInGroup?.length === 0) return null;
|
if (taxesInGroup?.length === 0) return null;
|
||||||
|
|
||||||
return (
|
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) => {
|
{taxesInGroup?.map((item) => {
|
||||||
const tax = taxCatalog.findByCode(item.tax_code).match(
|
const tax = taxCatalog.findByCode(item.tax_code).match(
|
||||||
(t) => t,
|
(t) => t,
|
||||||
@ -118,11 +120,11 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="flex items-center justify-between text-sm"
|
||||||
key={`${group}:${item.tax_code}`}
|
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="text-muted-foreground text-sm">{tax?.name}</span>
|
||||||
<span className='font-medium tabular-nums text-sm text-muted-foreground'>
|
<span className="font-medium tabular-nums text-sm text-muted-foreground">
|
||||||
{formatCurrency(item.taxes_amount, 2, currency_code, language_code)}
|
{formatCurrency(item.taxes_amount, 2, currency_code, language_code)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -132,9 +134,9 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div className='flex justify-between text-sm mt-3'>
|
<div className="flex justify-between text-sm mt-3">
|
||||||
<span className='text-foreground'>Total de impuestos</span>
|
<span className="text-foreground">Total de impuestos</span>
|
||||||
<span className='font-medium tabular-nums'>
|
<span className="font-medium tabular-nums">
|
||||||
{formatCurrency(taxes_amount, 2, currency_code, language_code)}
|
{formatCurrency(taxes_amount, 2, currency_code, language_code)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -142,9 +144,9 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className='flex justify-between text-sm '>
|
<div className="flex justify-between text-sm ">
|
||||||
<span className='font-bold text-foreground'>Total de la factura</span>
|
<span className="font-bold text-foreground">Total de la factura</span>
|
||||||
<span className='font-bold tabular-nums'>
|
<span className="font-bold tabular-nums">
|
||||||
{formatCurrency(total_amount, 2, currency_code, language_code)}
|
{formatCurrency(total_amount, 2, currency_code, language_code)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -2,91 +2,92 @@ import { Badge, Button, Input, Label } from "@repo/shadcn-ui/components";
|
|||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../../../i18n";
|
||||||
import { InvoiceFormData } from "../../../schemas";
|
import type { InvoiceFormData } from "../../../../../schemas";
|
||||||
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
|
import { CustomerInvoiceTaxesMultiSelect } from "../../customer-invoice-taxes-multi-select";
|
||||||
import { CustomItemViewProps } from "./types";
|
|
||||||
|
|
||||||
export interface BlocksViewProps extends CustomItemViewProps { }
|
import type { CustomItemViewProps } from "./types";
|
||||||
|
|
||||||
|
export interface BlocksViewProps extends CustomItemViewProps {}
|
||||||
|
|
||||||
export const BlocksView = ({ items, removeItem, updateItem }: BlocksViewProps) => {
|
export const BlocksView = ({ items, removeItem, updateItem }: BlocksViewProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { control } = useFormContext<InvoiceFormData>();
|
const { control } = useFormContext<InvoiceFormData>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-4'>
|
<div className="space-y-4">
|
||||||
{items.map((item: any, index: number) => (
|
{items.map((item: any, index: number) => (
|
||||||
<div key={`item-${String(index)}`} className='border rounded-lg p-4 space-y-4'>
|
<div className="border rounded-lg p-4 space-y-4" key={`item-${String(index)}`}>
|
||||||
<div className='flex items-center justify-between'>
|
<div className="flex items-center justify-between">
|
||||||
<Badge variant='outline' className='text-xs'>
|
<Badge className="text-xs" variant="outline">
|
||||||
Línea {item.position}
|
Línea {item.position}
|
||||||
</Badge>
|
</Badge>
|
||||||
{items.length > 1 && (
|
{items.length > 1 && (
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
className="text-destructive hover:text-destructive"
|
||||||
size='sm'
|
|
||||||
onClick={() => removeItem(index)}
|
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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div className='col-span-full space-y-2'>
|
<div className="col-span-full space-y-2">
|
||||||
<Label className='text-sm font-medium'>Descripción</Label>
|
<Label className="text-sm font-medium">Descripción</Label>
|
||||||
<Input
|
<Input
|
||||||
value={item.description}
|
|
||||||
onChange={(e) => updateItem(index, "description", e.target.value)}
|
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>
|
||||||
|
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<Label className='text-sm font-medium'>Cantidad</Label>
|
<Label className="text-sm font-medium">Cantidad</Label>
|
||||||
<Input
|
<Input
|
||||||
type='number'
|
|
||||||
step='0.01'
|
|
||||||
value={item.quantity}
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateItem(index, "quantity", Number.parseFloat(e.target.value) || 0)
|
updateItem(index, "quantity", Number.parseFloat(e.target.value) || 0)
|
||||||
}
|
}
|
||||||
|
step="0.01"
|
||||||
|
type="number"
|
||||||
|
value={item.quantity}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<Label className='text-sm font-medium'>Precio Unitario</Label>
|
<Label className="text-sm font-medium">Precio Unitario</Label>
|
||||||
<Input
|
<Input
|
||||||
type='number'
|
|
||||||
step='0.0001'
|
|
||||||
value={item.unit_amount}
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateItem(index, "unit_amount", Number.parseFloat(e.target.value) || 0)
|
updateItem(index, "unit_amount", Number.parseFloat(e.target.value) || 0)
|
||||||
}
|
}
|
||||||
|
step="0.0001"
|
||||||
|
type="number"
|
||||||
|
value={item.unit_amount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<Label className='text-sm font-medium'>% Descuento</Label>
|
<Label className="text-sm font-medium">% Descuento</Label>
|
||||||
<Input
|
<Input
|
||||||
type='number'
|
|
||||||
step='0.0001'
|
|
||||||
value={item.discount_percentage}
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateItem(index, "discount_percentage", Number.parseFloat(e.target.value) || 0)
|
updateItem(index, "discount_percentage", Number.parseFloat(e.target.value) || 0)
|
||||||
}
|
}
|
||||||
|
step="0.0001"
|
||||||
|
type="number"
|
||||||
|
value={item.discount_percentage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-2 col-start-1'>
|
<div className="space-y-2 col-start-1">
|
||||||
<CustomerInvoiceTaxesMultiSelect
|
<CustomerInvoiceTaxesMultiSelect
|
||||||
control={control}
|
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")}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Calculated amounts */}
|
{/* Calculated amounts */}
|
||||||
<div className='bg-muted/30 rounded-lg p-3'>
|
<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="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<Label className='text-xs text-muted-foreground'>SUBTOTAL</Label>
|
<Label className="text-xs text-muted-foreground">SUBTOTAL</Label>
|
||||||
<p className='font-medium'>{formatCurrency(item.subtotal_amount)}</p>
|
<p className="font-medium">{formatCurrency(item.subtotal_amount)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className='text-xs text-muted-foreground'>DESCUENTO</Label>
|
<Label className="text-xs text-muted-foreground">DESCUENTO</Label>
|
||||||
<p className='font-medium'>{formatCurrency(item.discount_amount)}</p>
|
<p className="font-medium">{formatCurrency(item.discount_amount)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className='text-xs text-muted-foreground'>BASE IMPONIBLE</Label>
|
<Label className="text-xs text-muted-foreground">BASE IMPONIBLE</Label>
|
||||||
<p className='font-medium'>{formatCurrency(item.taxable_amount)}</p>
|
<p className="font-medium">{formatCurrency(item.taxable_amount)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className='text-xs text-muted-foreground'>IMPUESTOS</Label>
|
<Label className="text-xs text-muted-foreground">IMPUESTOS</Label>
|
||||||
<p className='font-medium'>{formatCurrency(item.taxes_amount)}</p>
|
<p className="font-medium">{formatCurrency(item.taxes_amount)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className='text-xs text-muted-foreground'>TOTAL</Label>
|
<Label className="text-xs text-muted-foreground">TOTAL</Label>
|
||||||
<p className='font-semibold text-primary'>{formatCurrency(item.total_amount)}</p>
|
<p className="font-semibold text-primary">{formatCurrency(item.total_amount)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 { DataTable, useWithRowSelection } from "@repo/rdx-ui/components";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||||
import { useInvoiceContext } from "../../../context";
|
|
||||||
import { useInvoiceAutoRecalc } from "../../../hooks";
|
import { useInvoiceContext } from "../../../../../context";
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useInvoiceAutoRecalc } from "../../../../../hooks";
|
||||||
import { defaultCustomerInvoiceItemFormData, InvoiceFormData } from "../../../schemas";
|
import { useTranslation } from "../../../../../i18n";
|
||||||
|
import { type InvoiceFormData, defaultCustomerInvoiceItemFormData } from "../../../../../schemas";
|
||||||
|
|
||||||
import { ItemRowEditor } from "./item-row-editor";
|
import { ItemRowEditor } from "./item-row-editor";
|
||||||
import { useItemsColumns } from "./use-items-columns";
|
import { useItemsColumns } from "./use-items-columns";
|
||||||
|
|
||||||
@ -27,10 +29,13 @@ export const ItemsEditor = () => {
|
|||||||
const columns = useMemo(() => baseColumns, [baseColumns]);
|
const columns = useMemo(() => baseColumns, [baseColumns]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-0'>
|
<div className="space-y-0">
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns as any}
|
columns={columns as any}
|
||||||
data={fields}
|
data={fields}
|
||||||
|
EditorComponent={ItemRowEditor}
|
||||||
|
enablePagination={false}
|
||||||
|
enableRowSelection
|
||||||
meta={{
|
meta={{
|
||||||
tableOps: {
|
tableOps: {
|
||||||
onAdd: () => append({ ...createEmptyItem() }),
|
onAdd: () => append({ ...createEmptyItem() }),
|
||||||
@ -72,11 +77,8 @@ export const ItemsEditor = () => {
|
|||||||
moveSelectedDown: (indexes) => [...indexes].reverse().forEach((i) => move(i, i + 1)),
|
moveSelectedDown: (indexes) => [...indexes].reverse().forEach((i) => move(i, i + 1)),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
enableRowSelection
|
|
||||||
enablePagination={false}
|
|
||||||
pageSize={999}
|
pageSize={999}
|
||||||
readOnly={false}
|
readOnly={false}
|
||||||
EditorComponent={ItemRowEditor}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -17,10 +17,12 @@ import {
|
|||||||
import { ChevronDownIcon, ChevronUpIcon, CopyIcon, Plus, TrashIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronUpIcon, CopyIcon, Plus, TrashIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useFormContext } from "react-hook-form";
|
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 { HoverCardTotalsSummary } from "./hover-card-total-summary";
|
||||||
import { CustomItemViewProps } from "./types";
|
import type { CustomItemViewProps } from "./types";
|
||||||
|
|
||||||
export interface TableViewProps extends CustomItemViewProps {}
|
export interface TableViewProps extends CustomItemViewProps {}
|
||||||
|
|
||||||
@ -90,63 +92,62 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-4'>
|
<div className="space-y-4">
|
||||||
<div className='rounded-lg border border-border'>
|
<div className="rounded-lg border border-border">
|
||||||
<Table className='min-w-full'>
|
<Table className="min-w-full">
|
||||||
<TableHeader className='sticky top-0 z-20 bg-background shadow-sm'>
|
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
|
||||||
<TableRow className='bg-muted/30 text-xs text-muted-foreground'>
|
<TableRow className="bg-muted/30 text-xs text-muted-foreground">
|
||||||
<TableHead className='w-10 text-center'>#</TableHead>
|
<TableHead className="w-10 text-center">#</TableHead>
|
||||||
<TableHead>{t("form_fields.item.description.label")}</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")}
|
{t("form_fields.item.quantity.label")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className='text-right w-32'>
|
<TableHead className="text-right w-32">
|
||||||
{t("form_fields.item.unit_amount.label")}
|
{t("form_fields.item.unit_amount.label")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className='text-right w-24'>
|
<TableHead className="text-right w-24">
|
||||||
{t("form_fields.item.discount_percentage.label")}
|
{t("form_fields.item.discount_percentage.label")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className='text-right w-32'>
|
<TableHead className="text-right w-32">
|
||||||
{t("form_fields.item.tax_codes.label")}
|
{t("form_fields.item.tax_codes.label")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className='text-right w-32'>
|
<TableHead className="text-right w-32">
|
||||||
{t("form_fields.item.total_amount.label")}
|
{t("form_fields.item.total_amount.label")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className='w-44 text-center'>{t("common.actions")}</TableHead>
|
<TableHead className="w-44 text-center">{t("common.actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{lines.map((item, i) => (
|
{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 */}
|
{/* Í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}
|
{i + 1}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* DESCRIPCIÓN */}
|
{/* DESCRIPCIÓN */}
|
||||||
|
|
||||||
<TableCell className='align-top'>
|
<TableCell className="align-top">
|
||||||
<Textarea
|
<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}`}
|
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}
|
spellCheck={true}
|
||||||
autoComplete='off'
|
value={item.description}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* CANTIDAD */}
|
{/* CANTIDAD */}
|
||||||
<TableCell className='text-right'>
|
<TableCell className="text-right">
|
||||||
<Input
|
<Input
|
||||||
type='number'
|
aria-label={`Cantidad línea ${i + 1}`}
|
||||||
inputMode='decimal'
|
className="text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1"
|
||||||
className='text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1'
|
inputMode="decimal"
|
||||||
value={Number(item.quantity.value) / 10 ** Number(item.quantity.scale)}
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateItem(i, {
|
updateItem(i, {
|
||||||
quantity: {
|
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>
|
</TableCell>
|
||||||
|
|
||||||
{/* PRECIO UNITARIO */}
|
{/* PRECIO UNITARIO */}
|
||||||
<TableCell className='text-right'>
|
<TableCell className="text-right">
|
||||||
<Input
|
<Input
|
||||||
type='number'
|
aria-label={`Precio unitario línea ${i + 1}`}
|
||||||
inputMode='decimal'
|
className="text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1"
|
||||||
className='text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1'
|
inputMode="decimal"
|
||||||
value={Number(item.unit_amount.value) / 10 ** Number(item.unit_amount.scale)}
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateItem(i, {
|
updateItem(i, {
|
||||||
unit_amount: {
|
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>
|
</TableCell>
|
||||||
|
|
||||||
{/* DESCUENTO */}
|
{/* DESCUENTO */}
|
||||||
<TableCell className='text-right'>
|
<TableCell className="text-right">
|
||||||
<Input
|
<Input
|
||||||
type='number'
|
aria-label={`Descuento línea ${i + 1}`}
|
||||||
inputMode='decimal'
|
className="text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1"
|
||||||
className='text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1'
|
inputMode="decimal"
|
||||||
value={
|
|
||||||
Number(item.discount_percentage.value) /
|
|
||||||
10 ** Number(item.discount_percentage.scale)
|
|
||||||
}
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateItem(i, {
|
updateItem(i, {
|
||||||
discount_percentage: {
|
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>
|
||||||
|
|
||||||
<TableCell className='text-right'></TableCell>
|
<TableCell className="text-right" />
|
||||||
|
|
||||||
{/* TOTAL */}
|
{/* TOTAL */}
|
||||||
<TableCell className='text-right font-mono'>
|
<TableCell className="text-right font-mono">
|
||||||
<HoverCardTotalsSummary item={item}>
|
<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)}
|
{format(item.total_amount)}
|
||||||
</span>
|
</span>
|
||||||
</HoverCardTotalsSummary>
|
</HoverCardTotalsSummary>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* ACCIONES */}
|
{/* ACCIONES */}
|
||||||
<TableCell className='text-center'>
|
<TableCell className="text-center">
|
||||||
<div className='flex items-center justify-center gap-1'>
|
<div className="flex items-center justify-center gap-1">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
className="h-7 w-7"
|
||||||
size='icon'
|
|
||||||
onClick={() => moveItem(i, "up")}
|
|
||||||
disabled={i === 0}
|
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>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Mover arriba</TooltipContent>
|
<TooltipContent>Mover arriba</TooltipContent>
|
||||||
@ -244,13 +246,13 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
className="h-7 w-7"
|
||||||
size='icon'
|
|
||||||
onClick={() => moveItem(i, "down")}
|
|
||||||
disabled={i === lines.length - 1}
|
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>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Mover abajo</TooltipContent>
|
<TooltipContent>Mover abajo</TooltipContent>
|
||||||
@ -261,12 +263,12 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
className="h-7 w-7"
|
||||||
size='icon'
|
|
||||||
onClick={() => duplicateItem(i)}
|
onClick={() => duplicateItem(i)}
|
||||||
className='h-7 w-7'
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<CopyIcon className='size-3.5' />
|
<CopyIcon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Duplicar línea</TooltipContent>
|
<TooltipContent>Duplicar línea</TooltipContent>
|
||||||
@ -277,12 +279,12 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||||
size='icon'
|
|
||||||
onClick={() => removeItem(i)}
|
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>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Eliminar línea</TooltipContent>
|
<TooltipContent>Eliminar línea</TooltipContent>
|
||||||
@ -297,12 +299,12 @@ export const TableView = ({ items, actions }: TableViewProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
aria-label="Agregar nueva línea"
|
||||||
|
className="w-full border-dashed bg-transparent"
|
||||||
onClick={addNewItem}
|
onClick={addNewItem}
|
||||||
variant='outline'
|
variant="outline"
|
||||||
className='w-full border-dashed bg-transparent'
|
|
||||||
aria-label='Agregar nueva línea'
|
|
||||||
>
|
>
|
||||||
<Plus className='h-4 w-4 mr-2' />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Agregar línea
|
Agregar línea
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -4,8 +4,10 @@ import { cn } from "@repo/shadcn-ui/lib/utils";
|
|||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import { useInvoiceContext } from "../../../context";
|
|
||||||
|
import { useInvoiceContext } from "../../../../../context";
|
||||||
import { CustomerInvoiceTaxesMultiSelect } from "../../customer-invoice-taxes-multi-select";
|
import { CustomerInvoiceTaxesMultiSelect } from "../../customer-invoice-taxes-multi-select";
|
||||||
|
|
||||||
import { AmountInputField } from "./amount-input-field";
|
import { AmountInputField } from "./amount-input-field";
|
||||||
import { HoverCardTotalsSummary } from "./hover-card-total-summary";
|
import { HoverCardTotalsSummary } from "./hover-card-total-summary";
|
||||||
import { ItemDataTableRowActions } from "./items-data-table-row-actions";
|
import { ItemDataTableRowActions } from "./items-data-table-row-actions";
|
||||||
@ -38,7 +40,7 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
|
|||||||
{
|
{
|
||||||
id: "position",
|
id: "position",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title={"#"} className='text-center' />
|
<DataTableColumnHeader className="text-center" column={column} title={"#"} />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => row.index + 1,
|
cell: ({ row }) => row.index + 1,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
@ -48,9 +50,9 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
|
|||||||
accessorKey: "description",
|
accessorKey: "description",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader
|
<DataTableColumnHeader
|
||||||
|
className="text-left"
|
||||||
column={column}
|
column={column}
|
||||||
title={t("form_fields.item.description.label")}
|
title={t("form_fields.item.description.label")}
|
||||||
className='text-left'
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@ -61,23 +63,23 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
|
|||||||
<InputGroup>
|
<InputGroup>
|
||||||
<InputGroupTextarea
|
<InputGroupTextarea
|
||||||
{...field}
|
{...field}
|
||||||
id={`desc-${row.original.id}`} // ← estable
|
aria-label={t("form_fields.item.description.label")} // ← 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`;
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-48 max-w-184 w-full resize-none bg-transparent border-dashed transition",
|
"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-visible:ring-2 focus-visible:ring-ring focus-visible:bg-background focus-visible:border-solid",
|
||||||
"focus:resize-y"
|
"focus:resize-y"
|
||||||
)}
|
)}
|
||||||
data-cell-focus
|
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">
|
{/*<InputGroupAddon align="block-end">
|
||||||
<InputGroupText>Line 1, Column 1</InputGroupText>
|
<InputGroupText>Line 1, Column 1</InputGroupText>
|
||||||
@ -104,22 +106,22 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
|
|||||||
accessorKey: "quantity",
|
accessorKey: "quantity",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader
|
<DataTableColumnHeader
|
||||||
|
className="text-right"
|
||||||
column={column}
|
column={column}
|
||||||
title={t("form_fields.item.quantity.label")}
|
title={t("form_fields.item.quantity.label")}
|
||||||
className='text-right'
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<QuantityInputField
|
<QuantityInputField
|
||||||
|
className="font-base"
|
||||||
control={control}
|
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`}
|
name={`items.${row.index}.quantity`}
|
||||||
readOnly={readOnly}
|
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,
|
enableSorting: false,
|
||||||
@ -131,24 +133,24 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
|
|||||||
accessorKey: "unit_amount",
|
accessorKey: "unit_amount",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader
|
<DataTableColumnHeader
|
||||||
|
className="text-right"
|
||||||
column={column}
|
column={column}
|
||||||
title={t("form_fields.item.unit_amount.label")}
|
title={t("form_fields.item.unit_amount.label")}
|
||||||
className='text-right'
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<AmountInputField
|
<AmountInputField
|
||||||
|
className="font-base"
|
||||||
control={control}
|
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`}
|
name={`items.${row.index}.unit_amount`}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
inputId={`unit-${row.original.id}`}
|
|
||||||
scale={4}
|
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,
|
enableSorting: false,
|
||||||
@ -160,22 +162,22 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
|
|||||||
accessorKey: "discount_percentage",
|
accessorKey: "discount_percentage",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader
|
<DataTableColumnHeader
|
||||||
|
className="text-right"
|
||||||
column={column}
|
column={column}
|
||||||
title={t("form_fields.item.discount_percentage.label")}
|
title={t("form_fields.item.discount_percentage.label")}
|
||||||
className='text-right'
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<PercentageInputField
|
<PercentageInputField
|
||||||
|
className="font-base"
|
||||||
control={control}
|
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`}
|
name={`items.${row.index}.discount_percentage`}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
inputId={`disc-${row.original.id}`}
|
|
||||||
scale={4}
|
scale={4}
|
||||||
data-row-index={row.index}
|
|
||||||
data-col-index={6}
|
|
||||||
data-cell-focus
|
|
||||||
className='font-base'
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
@ -186,19 +188,19 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
|
|||||||
accessorKey: "discount_amount",
|
accessorKey: "discount_amount",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader
|
<DataTableColumnHeader
|
||||||
|
className="text-right"
|
||||||
column={column}
|
column={column}
|
||||||
title={t("form_fields.item.discount_amount.label")}
|
title={t("form_fields.item.discount_amount.label")}
|
||||||
className='text-right'
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<AmountInputField
|
<AmountInputField
|
||||||
control={control}
|
control={control}
|
||||||
|
currencyCode={currency_code}
|
||||||
|
inputId={`discount_amount-${row.original.id}`}
|
||||||
|
languageCode={language_code}
|
||||||
name={`items.${row.index}.discount_amount`}
|
name={`items.${row.index}.discount_amount`}
|
||||||
readOnly
|
readOnly
|
||||||
inputId={`discount_amount-${row.original.id}`}
|
|
||||||
currencyCode={currency_code}
|
|
||||||
languageCode={language_code}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableHiding: true,
|
enableHiding: true,
|
||||||
@ -211,19 +213,19 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
|
|||||||
accessorKey: "taxable_amount",
|
accessorKey: "taxable_amount",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader
|
<DataTableColumnHeader
|
||||||
|
className="text-right"
|
||||||
column={column}
|
column={column}
|
||||||
title={t("form_fields.item.taxable_amount.label")}
|
title={t("form_fields.item.taxable_amount.label")}
|
||||||
className='text-right'
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<AmountInputField
|
<AmountInputField
|
||||||
control={control}
|
control={control}
|
||||||
|
currencyCode={currency_code}
|
||||||
|
inputId={`taxable_amount-${row.original.id}`}
|
||||||
|
languageCode={language_code}
|
||||||
name={`items.${row.index}.taxable_amount`}
|
name={`items.${row.index}.taxable_amount`}
|
||||||
readOnly
|
readOnly
|
||||||
inputId={`taxable_amount-${row.original.id}`}
|
|
||||||
currencyCode={currency_code}
|
|
||||||
languageCode={language_code}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableHiding: true,
|
enableHiding: true,
|
||||||
@ -244,10 +246,10 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<CustomerInvoiceTaxesMultiSelect
|
<CustomerInvoiceTaxesMultiSelect
|
||||||
{...field}
|
{...field}
|
||||||
inputId={`tax-${row.original.id}`}
|
|
||||||
data-row-index={row.index}
|
|
||||||
data-col-index={7}
|
|
||||||
data-cell-focus
|
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",
|
accessorKey: "taxes_amount",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader
|
<DataTableColumnHeader
|
||||||
|
className="text-right"
|
||||||
column={column}
|
column={column}
|
||||||
title={t("form_fields.item.taxes_amount.label")}
|
title={t("form_fields.item.taxes_amount.label")}
|
||||||
className='text-right'
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<AmountInputField
|
<AmountInputField
|
||||||
control={control}
|
control={control}
|
||||||
|
currencyCode={currency_code}
|
||||||
|
inputId={`taxes_amount-${row.original.id}`}
|
||||||
|
languageCode={language_code}
|
||||||
name={`items.${row.index}.taxes_amount`}
|
name={`items.${row.index}.taxes_amount`}
|
||||||
readOnly
|
readOnly
|
||||||
inputId={`taxes_amount-${row.original.id}`}
|
|
||||||
currencyCode={currency_code}
|
|
||||||
languageCode={language_code}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
@ -285,21 +287,21 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
|
|||||||
accessorKey: "total_amount",
|
accessorKey: "total_amount",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader
|
<DataTableColumnHeader
|
||||||
|
className="text-right"
|
||||||
column={column}
|
column={column}
|
||||||
title={t("form_fields.item.total_amount.label")}
|
title={t("form_fields.item.total_amount.label")}
|
||||||
className='text-right'
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<HoverCardTotalsSummary rowIndex={row.index}>
|
<HoverCardTotalsSummary rowIndex={row.index}>
|
||||||
<AmountInputField
|
<AmountInputField
|
||||||
|
className="font-semibold"
|
||||||
control={control}
|
control={control}
|
||||||
|
currencyCode={currency_code}
|
||||||
|
inputId={`total-${row.original.id}`}
|
||||||
|
languageCode={language_code}
|
||||||
name={`items.${row.index}.total_amount`}
|
name={`items.${row.index}.total_amount`}
|
||||||
readOnly
|
readOnly
|
||||||
inputId={`total-${row.original.id}`}
|
|
||||||
currencyCode={currency_code}
|
|
||||||
languageCode={language_code}
|
|
||||||
className='font-semibold'
|
|
||||||
/>
|
/>
|
||||||
</HoverCardTotalsSummary>
|
</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 { useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import { ComponentProps } from 'react';
|
import { useTranslation } from "../../../../../i18n";
|
||||||
import { useTranslation } from "../../../i18n";
|
|
||||||
import { RecipientModalSelectorField } from "./recipient-modal-selector-field";
|
import { RecipientModalSelectorField } from "./recipient-modal-selector-field";
|
||||||
|
|
||||||
export const InvoiceRecipient = (props: ComponentProps<"fieldset">) => {
|
export const InvoiceRecipient = (props: ComponentProps<"fieldset">) => {
|
||||||
@ -13,17 +14,19 @@ export const InvoiceRecipient = (props: ComponentProps<"fieldset">) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldSet {...props}>
|
<FieldSet {...props}>
|
||||||
<FieldLegend className='hidden text-foreground' variant='label'>
|
<FieldLegend className="hidden text-foreground" variant="label">
|
||||||
{t('form_groups.recipient.title')}
|
{t("form_groups.recipient.title")}
|
||||||
</FieldLegend>
|
</FieldLegend>
|
||||||
<FieldDescription className='hidden'>{t("form_groups.recipient.description")}</FieldDescription>
|
<FieldDescription className="hidden">
|
||||||
|
{t("form_groups.recipient.description")}
|
||||||
|
</FieldDescription>
|
||||||
|
|
||||||
<FieldGroup className='flex flex-row flex-wrap gap-6 xl:flex-nowrap'>
|
<FieldGroup className="flex flex-row flex-wrap gap-6 xl:flex-nowrap">
|
||||||
<RecipientModalSelectorField
|
<RecipientModalSelectorField
|
||||||
control={control}
|
control={control}
|
||||||
name='customer_id'
|
|
||||||
label={t('form_groups.customer.title')}
|
|
||||||
initialRecipient={recipient}
|
initialRecipient={recipient}
|
||||||
|
label={t("form_groups.customer.title")}
|
||||||
|
name="customer_id"
|
||||||
/>
|
/>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
@ -2,8 +2,6 @@ export * from "./customer-invoice-editor-skeleton";
|
|||||||
export * from "./customer-invoice-prices-card";
|
export * from "./customer-invoice-prices-card";
|
||||||
export * from "./customer-invoice-status-badge";
|
export * from "./customer-invoice-status-badge";
|
||||||
export * from "./customer-invoice-taxes-multi-select";
|
export * from "./customer-invoice-taxes-multi-select";
|
||||||
export * from "./customer-invoices-layout";
|
|
||||||
export * from "./editor";
|
export * from "./editor";
|
||||||
export * from "./editor/invoice-tax-summary";
|
export * from "./editor/invoice-tax-summary";
|
||||||
export * from "./editor/invoice-totals";
|
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