Facturas de cliente
This commit is contained in:
parent
747d11a956
commit
169de55b0a
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -33,7 +33,7 @@
|
|||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
},
|
},
|
||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "vscode.json-language-features"
|
||||||
},
|
},
|
||||||
"[jsonc]": {
|
"[jsonc]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
|||||||
@ -173,7 +173,7 @@
|
|||||||
"noThisInStatic": "error",
|
"noThisInStatic": "error",
|
||||||
"noUselessCatch": "error",
|
"noUselessCatch": "error",
|
||||||
"noUselessConstructor": "error",
|
"noUselessConstructor": "error",
|
||||||
"noUselessFragments": "error",
|
"noUselessFragments": "off",
|
||||||
"noUselessLabel": "error",
|
"noUselessLabel": "error",
|
||||||
"noUselessRename": "error",
|
"noUselessRename": "error",
|
||||||
"noUselessSwitchCase": "error",
|
"noUselessSwitchCase": "error",
|
||||||
@ -184,7 +184,7 @@
|
|||||||
"useLiteralKeys": "error",
|
"useLiteralKeys": "error",
|
||||||
"useOptionalChain": "error",
|
"useOptionalChain": "error",
|
||||||
"useSimpleNumberKeys": "error",
|
"useSimpleNumberKeys": "error",
|
||||||
"useSimplifiedLogicExpression": "error"
|
"useSimplifiedLogicExpression": "info"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"noDangerouslySetInnerHtml": "error",
|
"noDangerouslySetInnerHtml": "error",
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export function mockUser(req: RequestWithAuth, _res: Response, next: NextFunctio
|
|||||||
req.user = {
|
req.user = {
|
||||||
userId: UniqueID.create("9e4dc5b3-96b9-4968-9490-14bd032fec5f").data,
|
userId: UniqueID.create("9e4dc5b3-96b9-4968-9490-14bd032fec5f").data,
|
||||||
email: EmailAddress.create("dev@example.com").data,
|
email: EmailAddress.create("dev@example.com").data,
|
||||||
companyId: UniqueID.create("5e4dc5b3-96b9-4968-9490-14bd032fec5f").data,
|
companyId: UniqueID.create("019a9667-6a65-767a-a737-48234ee50a3a").data,
|
||||||
companySlug: "acana",
|
companySlug: "acana",
|
||||||
roles: ["admin"],
|
roles: ["admin"],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -58,6 +58,7 @@ const format = (
|
|||||||
// Respetar fracciones si no vienen dadas en options.
|
// Respetar fracciones si no vienen dadas en options.
|
||||||
const nfOptions: Intl.NumberFormatOptions = {
|
const nfOptions: Intl.NumberFormatOptions = {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
|
useGrouping: true,
|
||||||
currency: normalizedDTO.currency_code,
|
currency: normalizedDTO.currency_code,
|
||||||
minimumFractionDigits: options?.minimumFractionDigits ?? scale,
|
minimumFractionDigits: options?.minimumFractionDigits ?? scale,
|
||||||
maximumFractionDigits: options?.maximumFractionDigits ?? scale,
|
maximumFractionDigits: options?.maximumFractionDigits ?? scale,
|
||||||
|
|||||||
@ -41,7 +41,7 @@ const toNumericString = (dto?: PercentageDTO | null, fallbackScale = 2): string
|
|||||||
const format = (
|
const format = (
|
||||||
dto: PercentageDTO,
|
dto: PercentageDTO,
|
||||||
locale?: string,
|
locale?: string,
|
||||||
options?: Intl.NumberFormatOptions,
|
options?: { hideZeros?: boolean } & Intl.NumberFormatOptions,
|
||||||
fallbackScale = 2
|
fallbackScale = 2
|
||||||
): string => {
|
): string => {
|
||||||
if (isEmptyPercentageDTO(dto)) {
|
if (isEmptyPercentageDTO(dto)) {
|
||||||
@ -59,6 +59,9 @@ const format = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const absolute = toNumber(dto, fallbackScale); // ej. 12.5
|
const absolute = toNumber(dto, fallbackScale); // ej. 12.5
|
||||||
|
|
||||||
|
if (absolute === 0 && options?.hideZeros) return "";
|
||||||
|
|
||||||
const fraction = absolute / 100; // ej. 0.125 para Intl percent
|
const fraction = absolute / 100; // ej. 0.125 para Intl percent
|
||||||
|
|
||||||
return new Intl.NumberFormat(locale, nfOptions).format(fraction);
|
return new Intl.NumberFormat(locale, nfOptions).format(fraction);
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { AxiosInstance } from "axios";
|
import type { AxiosInstance } from "axios";
|
||||||
import { ICustomParams, IDataSource } from "../datasource.interface";
|
|
||||||
|
import type { ICustomParams, IDataSource } from "../datasource.interface";
|
||||||
|
|
||||||
import { defaultAxiosRequestConfig } from "./create-axios-instance";
|
import { defaultAxiosRequestConfig } from "./create-axios-instance";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -58,6 +58,7 @@
|
|||||||
"puppeteer": "^24.30.0",
|
"puppeteer": "^24.30.0",
|
||||||
"react-hook-form": "^7.58.1",
|
"react-hook-form": "^7.58.1",
|
||||||
"react-i18next": "^15.5.1",
|
"react-i18next": "^15.5.1",
|
||||||
|
"react-qr-code": "^2.0.18",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.5",
|
||||||
"zod": "^4.1.11"
|
"zod": "^4.1.11"
|
||||||
|
|||||||
@ -55,7 +55,11 @@ export class IssuedInvoiceReportPresenter extends Presenter<
|
|||||||
locale,
|
locale,
|
||||||
moneyOptions
|
moneyOptions
|
||||||
),
|
),
|
||||||
discount_percentage: PercentageDTOHelper.format(issuedInvoiceDTO.discount_percentage, locale),
|
discount_percentage: PercentageDTOHelper.format(
|
||||||
|
issuedInvoiceDTO.discount_percentage,
|
||||||
|
locale,
|
||||||
|
{ hideZeros: true }
|
||||||
|
),
|
||||||
discount_amount: MoneyDTOHelper.format(
|
discount_amount: MoneyDTOHelper.format(
|
||||||
issuedInvoiceDTO.discount_amount,
|
issuedInvoiceDTO.discount_amount,
|
||||||
locale,
|
locale,
|
||||||
|
|||||||
@ -23,6 +23,9 @@ export class IssuedInvoiceReportHTMLPresenter extends TemplatePresenter {
|
|||||||
const invoiceDTO = dtoPresenter.toOutput(invoice);
|
const invoiceDTO = dtoPresenter.toOutput(invoice);
|
||||||
const prettyDTO = prePresenter.toOutput(invoiceDTO);
|
const prettyDTO = prePresenter.toOutput(invoiceDTO);
|
||||||
|
|
||||||
|
console.log(prettyDTO.verifactu);
|
||||||
|
|
||||||
|
|
||||||
// Obtener y compilar la plantilla HTML
|
// Obtener y compilar la plantilla HTML
|
||||||
const template = this.templateResolver.compileTemplate(
|
const template = this.templateResolver.compileTemplate(
|
||||||
"customer-invoices",
|
"customer-invoices",
|
||||||
|
|||||||
@ -23,6 +23,8 @@ export class IssuedInvoiceReportPDFPresenter extends Presenter<
|
|||||||
format: "HTML",
|
format: "HTML",
|
||||||
}) as IssuedInvoiceReportHTMLPresenter;
|
}) as IssuedInvoiceReportHTMLPresenter;
|
||||||
|
|
||||||
|
console.log(invoice);
|
||||||
|
|
||||||
const htmlData = htmlPresenter.toOutput(invoice, params);
|
const htmlData = htmlPresenter.toOutput(invoice, params);
|
||||||
|
|
||||||
// Generar el PDF con Puppeteer
|
// Generar el PDF con Puppeteer
|
||||||
@ -33,8 +35,8 @@ export class IssuedInvoiceReportPDFPresenter extends Presenter<
|
|||||||
});
|
});
|
||||||
|
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
//page.setDefaultNavigationTimeout(60000);
|
page.setDefaultNavigationTimeout(60000);
|
||||||
//page.setDefaultTimeout(60000);
|
page.setDefaultTimeout(60000);
|
||||||
|
|
||||||
await page.setContent(htmlData, {
|
await page.setContent(htmlData, {
|
||||||
waitUntil: "networkidle2",
|
waitUntil: "networkidle2",
|
||||||
|
|||||||
@ -33,8 +33,8 @@ export class ProformaReportPDFPresenter extends Presenter<
|
|||||||
});
|
});
|
||||||
|
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
//page.setDefaultNavigationTimeout(60000);
|
page.setDefaultNavigationTimeout(60000);
|
||||||
//page.setDefaultTimeout(60000);
|
page.setDefaultTimeout(60000);
|
||||||
|
|
||||||
await page.setContent(htmlData, {
|
await page.setContent(htmlData, {
|
||||||
waitUntil: "networkidle2",
|
waitUntil: "networkidle2",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Tax, Taxes } from "@erp/core/api";
|
import { type Tax, Taxes } from "@erp/core/api";
|
||||||
|
|
||||||
import { ItemAmount } from "../../value-objects";
|
import { ItemAmount } from "../../value-objects";
|
||||||
|
|
||||||
export type ItemTaxTotal = {
|
export type ItemTaxTotal = {
|
||||||
|
|||||||
@ -360,7 +360,7 @@ export class CustomerInvoiceRepository
|
|||||||
model: VerifactuRecordModel,
|
model: VerifactuRecordModel,
|
||||||
as: "verifactu",
|
as: "verifactu",
|
||||||
required: false,
|
required: false,
|
||||||
attributes: ["id", "estado", "url", "uuid"],
|
attributes: ["id", "estado", "url", "uuid", "qr"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: CustomerModel,
|
model: CustomerModel,
|
||||||
@ -578,7 +578,7 @@ export class CustomerInvoiceRepository
|
|||||||
model: VerifactuRecordModel,
|
model: VerifactuRecordModel,
|
||||||
as: "verifactu",
|
as: "verifactu",
|
||||||
required: false,
|
required: false,
|
||||||
attributes: ["id", "estado", "url", "uuid"],
|
attributes: ["id", "estado", "url", "uuid", "qr"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: CustomerModel,
|
model: CustomerModel,
|
||||||
|
|||||||
@ -57,7 +57,9 @@ export class CustomerInvoiceItemTaxModel extends Model<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static hooks(_database: Sequelize) {}
|
static hooks(_database: Sequelize) {
|
||||||
|
//
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (database: Sequelize) => {
|
export default (database: Sequelize) => {
|
||||||
|
|||||||
@ -421,7 +421,7 @@ export default (database: Sequelize) => {
|
|||||||
|
|
||||||
{
|
{
|
||||||
name: "idx_invoice_company_series_number",
|
name: "idx_invoice_company_series_number",
|
||||||
fields: ["company_id", "series", "invoice_number"],
|
fields: ["company_id", "series", "invoice_number", "is_proforma"],
|
||||||
unique: true,
|
unique: true,
|
||||||
}, // <- para consulta get
|
}, // <- para consulta get
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export class VerifactuRecordModel extends Model<
|
|||||||
declare estado: string;
|
declare estado: string;
|
||||||
|
|
||||||
declare url: CreationOptional<string>;
|
declare url: CreationOptional<string>;
|
||||||
declare qr: CreationOptional<Blob>;
|
declare qr: CreationOptional<string>;
|
||||||
declare uuid: CreationOptional<string>;
|
declare uuid: CreationOptional<string>;
|
||||||
declare operacion: CreationOptional<string>;
|
declare operacion: CreationOptional<string>;
|
||||||
|
|
||||||
@ -77,7 +77,7 @@ export default (database: Sequelize) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
qr: {
|
qr: {
|
||||||
type: new DataTypes.BLOB(),
|
type: new DataTypes.TEXT(),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -50,6 +50,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"proformas": {
|
||||||
|
"delete_proforma_dialog": {
|
||||||
|
"title": "Delete proforma",
|
||||||
|
"description": "Are you sure you want to delete proforma <strong>{{proformaRef}}</strong>? This action cannot be undone.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleting": "Deleting...",
|
||||||
|
"success_title": "Proforma deleted",
|
||||||
|
"error_title": "Error deleting proforma"
|
||||||
|
}
|
||||||
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"proformas": {
|
"proformas": {
|
||||||
"title": "Proformas",
|
"title": "Proformas",
|
||||||
|
|||||||
@ -49,6 +49,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"proformas": {
|
||||||
|
"delete_proforma_dialog": {
|
||||||
|
"title": "Eliminar proforma",
|
||||||
|
"description": "¿Seguro que deseas eliminar la proforma <strong>{{proformaRef}}</strong>? Esta acción no se puede deshacer.",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"deleting": "Eliminando...",
|
||||||
|
"success_title": "Proforma eliminada",
|
||||||
|
"error_title": "Error al eliminar la proforma"
|
||||||
|
}
|
||||||
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"proformas": {
|
"proformas": {
|
||||||
"title": "Proformas",
|
"title": "Proformas",
|
||||||
|
|||||||
@ -11,7 +11,7 @@ const IssuedInvoicesLayout = lazy(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ProformasListPage = lazy(() =>
|
const ProformasListPage = lazy(() =>
|
||||||
import("./proformas/pages").then((m) => ({ default: m.ProformaListPage }))
|
import("./proformas/list").then((m) => ({ default: m.ProformaListPage }))
|
||||||
);
|
);
|
||||||
|
|
||||||
const IssuedInvoiceListPage = lazy(() =>
|
const IssuedInvoiceListPage = lazy(() =>
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
calculateInvoiceHeaderAmounts,
|
calculateInvoiceHeaderAmounts,
|
||||||
calculateInvoiceItemAmounts,
|
calculateInvoiceItemAmounts,
|
||||||
} from "../../domain";
|
} from "../../domain";
|
||||||
import type { ProformaFormData } from "../../proformas/schema";
|
import type { ProformaFormData } from "../../proformas/types";
|
||||||
import type { InvoiceFormData, InvoiceItemFormData } from "../../schemas";
|
import type { InvoiceFormData, InvoiceItemFormData } from "../../schemas";
|
||||||
|
|
||||||
export type UseProformaAutoRecalcParams = {
|
export type UseProformaAutoRecalcParams = {
|
||||||
|
|||||||
@ -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/schema/proforma.api.schema";
|
import type { Proforma } from "../proformas/types/proforma.api.schema";
|
||||||
import type { InvoiceFormData } from "../schemas";
|
import type { InvoiceFormData } from "../schemas";
|
||||||
|
|
||||||
type CreateCustomerInvoicePayload = {
|
type CreateCustomerInvoicePayload = {
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { KeyPrefix, Namespace, i18n } from "i18next";
|
import type { KeyPrefix, Namespace, i18n } from "i18next";
|
||||||
import { UseTranslationResponse, useTranslation as useI18NextTranslation } from "react-i18next";
|
import {
|
||||||
|
type UseTranslationResponse,
|
||||||
|
useTranslation as useI18NextTranslation,
|
||||||
|
} from "react-i18next";
|
||||||
|
|
||||||
import enResources from "../common/locales/en.json";
|
import enResources from "../common/locales/en.json";
|
||||||
import esResources from "../common/locales/es.json";
|
import esResources from "../common/locales/es.json";
|
||||||
|
|
||||||
import { MODULE_NAME } from "./manifest";
|
import { MODULE_NAME } from "./manifest";
|
||||||
|
|
||||||
const addMissingBundles = (i18n: i18n) => {
|
const addMissingBundles = (i18n: i18n) => {
|
||||||
|
|||||||
@ -12,8 +12,9 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { DownloadIcon, MailIcon, MoreVerticalIcon } from "lucide-react";
|
import { DownloadIcon, MailIcon, MoreVerticalIcon, QrCodeIcon } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import QRCode from "react-qr-code";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../i18n";
|
||||||
import type { IssuedInvoiceSummaryData } from "../../../schema";
|
import type { IssuedInvoiceSummaryData } from "../../../schema";
|
||||||
@ -84,38 +85,39 @@ export function useIssuedInvoicesGridColumns(
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
accessorFn: (row) => row.verifactu.qr_code, // para ordenar/buscar por nombre
|
accessorFn: (row) => row.verifactu.qr_code, // para ordenar/buscar por nombre
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<div className="font-medium text-left">{row.original.verifactu.qr_code}</div>
|
const { verifactu } = row.original;
|
||||||
),
|
const isPending = verifactu.status === "Pendiente";
|
||||||
|
console.log(verifactu.status);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isPending ? (
|
||||||
|
<QrCodeIcon className="size-8 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<a href={verifactu.url} rel="noopener" target="_blank">
|
||||||
|
<QrCodeIcon className="size-8" />
|
||||||
|
</a>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="m-0 p-3">
|
||||||
|
<QRCode className="bg-white p-8" value={verifactu.url} />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
size: 140,
|
maxSize: 64,
|
||||||
minSize: 120,
|
size: 64,
|
||||||
|
minSize: 64,
|
||||||
meta: {
|
meta: {
|
||||||
title: t("pages.issued_invoices.list.grid_columns.verifactu_qr_code"),
|
title: t("pages.issued_invoices.list.grid_columns.verifactu_qr_code"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "verifactu_url",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader
|
|
||||||
className="text-left"
|
|
||||||
column={column}
|
|
||||||
title={t("pages.issued_invoices.list.grid_columns.verifactu_url")}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
accessorFn: (row) => row.verifactu.url, // para ordenar/buscar por nombre
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="font-medium text-left">{row.original.verifactu.url}</div>
|
|
||||||
),
|
|
||||||
enableHiding: false,
|
|
||||||
enableSorting: false,
|
|
||||||
size: 140,
|
|
||||||
minSize: 120,
|
|
||||||
meta: {
|
|
||||||
title: t("pages.issued_invoices.list.grid_columns.verifactu_url"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "recipient",
|
id: "recipient",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
type TaxCatalogProvider,
|
type TaxCatalogProvider,
|
||||||
} from "@erp/core";
|
} from "@erp/core";
|
||||||
|
|
||||||
import type { Proforma, ProformaFormData, UpdateProformaInput } from "../schema";
|
import type { Proforma, ProformaFormData, UpdateProformaInput } from "../types";
|
||||||
|
|
||||||
export type ProformaDtoAdapterContext = {
|
export type ProformaDtoAdapterContext = {
|
||||||
taxCatalog: TaxCatalogProvider;
|
taxCatalog: TaxCatalogProvider;
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
|
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
|
||||||
|
|
||||||
import type { ProformaSummaryPage } from "../schema/proforma.api.schema";
|
import type { ProformaSummaryPage } from "../types/proforma.api.schema";
|
||||||
import type {
|
import type {
|
||||||
ProformaSummaryData,
|
ProformaSummaryData,
|
||||||
ProformaSummaryPageData,
|
ProformaSummaryPageData,
|
||||||
} from "../schema/proforma-summary.web.schema";
|
} from "../types/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.
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
import type { IDataSource } from "@erp/core/client";
|
||||||
|
|
||||||
|
export interface ChangeStatusResponse {
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeProformaStatusApi(
|
||||||
|
dataSource: IDataSource,
|
||||||
|
proformaId: string,
|
||||||
|
newStatus: string
|
||||||
|
): Promise<ChangeStatusResponse> {
|
||||||
|
return dataSource.custom<ChangeStatusResponse>({
|
||||||
|
path: `proformas/${proformaId}/status`,
|
||||||
|
method: "patch",
|
||||||
|
data: { new_status: newStatus },
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./change-proforma-status.api";
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./use-change-status-dialog-controller";
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
import { showErrorToast } from "@repo/rdx-ui/helpers";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import type { ProformaSummaryData } from "../../types";
|
||||||
|
import { useChangeProformaStatus } from "../hooks/use-change-proforma-status";
|
||||||
|
|
||||||
|
type ProformasType = {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
interface ChangeStatusDialogState {
|
||||||
|
open: boolean;
|
||||||
|
targetStatus: string | null;
|
||||||
|
proformas: ProformaSummaryData[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChangeStatusDialogController() {
|
||||||
|
const { changeStatus } = useChangeProformaStatus();
|
||||||
|
|
||||||
|
const [state, setState] = React.useState<ChangeStatusDialogState>({
|
||||||
|
open: false,
|
||||||
|
targetStatus: null,
|
||||||
|
proformas: [],
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const openDialog = (proformas: ProformaSummaryData[]) => {
|
||||||
|
setState({
|
||||||
|
open: true,
|
||||||
|
targetStatus: null,
|
||||||
|
proformas,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setState((s) => ({ ...s, open: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTargetStatus = (status: string | null) => {
|
||||||
|
setState((s) => ({ ...s, targetStatus: status }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmChangeStatus = async () => {
|
||||||
|
if (!state.targetStatus || state.proformas.length === 0) return;
|
||||||
|
|
||||||
|
setState((s) => ({ ...s, loading: true }));
|
||||||
|
|
||||||
|
for (const proforma of state.proformas) {
|
||||||
|
await changeStatus(proforma.id.toString(), state.targetStatus, {
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const error = err as Error;
|
||||||
|
showErrorToast("Error cambiando estado", error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((s) => ({ ...s, loading: false }));
|
||||||
|
closeDialog();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
open: state.open,
|
||||||
|
proformas: state.proformas,
|
||||||
|
targetStatus: state.targetStatus,
|
||||||
|
isSubmitting: state.loading,
|
||||||
|
|
||||||
|
openDialog,
|
||||||
|
closeDialog,
|
||||||
|
setTargetStatus,
|
||||||
|
confirmChangeStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./use-change-proforma-status";
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
import { useDataSource } from "@erp/core/hooks";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type { PROFORMA_STATUS } from "../../types";
|
||||||
|
import { changeProformaStatusApi } from "../api/change-proforma-status.api";
|
||||||
|
|
||||||
|
interface ChangeProformaStatusOptions {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onError?: (err: unknown) => void;
|
||||||
|
onLoadingChange?: (loading: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChangeProformaStatusPayload {
|
||||||
|
proformaId: string;
|
||||||
|
newStatus: PROFORMA_STATUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChangeProformaStatus() {
|
||||||
|
const dataSource = useDataSource();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: ({ proformaId, newStatus }: ChangeProformaStatusPayload) =>
|
||||||
|
changeProformaStatusApi(dataSource, proformaId, newStatus),
|
||||||
|
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["proformas"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function changeStatus(
|
||||||
|
proformaId: string,
|
||||||
|
newStatus: string,
|
||||||
|
opts?: ChangeProformaStatusOptions
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
opts?.onLoadingChange?.(true);
|
||||||
|
await mutation.mutateAsync({ proformaId, newStatus: newStatus as PROFORMA_STATUS });
|
||||||
|
opts?.onSuccess?.();
|
||||||
|
} catch (err) {
|
||||||
|
opts?.onError?.(err);
|
||||||
|
} finally {
|
||||||
|
opts?.onLoadingChange?.(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
changeStatus,
|
||||||
|
isPending: mutation.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./controllers";
|
||||||
|
export * from "./ui";
|
||||||
@ -0,0 +1,300 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Spinner,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import {
|
||||||
|
CheckCircle2Icon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
FileCheckIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
SendIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../i18n";
|
||||||
|
import { PROFORMA_STATUS, PROFORMA_STATUS_TRANSITIONS } from "../../types";
|
||||||
|
|
||||||
|
interface ChangeStatusDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
proformas: { id: string; status: string }[];
|
||||||
|
targetStatus?: string;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_FLOW = [
|
||||||
|
{
|
||||||
|
status: PROFORMA_STATUS.DRAFT,
|
||||||
|
icon: FileTextIcon,
|
||||||
|
color: "text-gray-500",
|
||||||
|
bgColor: "bg-gray-100",
|
||||||
|
borderColor: "border-gray-300",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: PROFORMA_STATUS.SENT,
|
||||||
|
icon: SendIcon,
|
||||||
|
color: "text-blue-600",
|
||||||
|
bgColor: "bg-blue-100",
|
||||||
|
borderColor: "border-blue-300",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: PROFORMA_STATUS.APPROVED,
|
||||||
|
icon: CheckCircle2Icon,
|
||||||
|
color: "text-green-600",
|
||||||
|
bgColor: "bg-green-100",
|
||||||
|
borderColor: "border-green-300",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: PROFORMA_STATUS.REJECTED,
|
||||||
|
icon: XCircleIcon,
|
||||||
|
color: "text-red-600",
|
||||||
|
bgColor: "bg-red-100",
|
||||||
|
borderColor: "border-red-300",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: PROFORMA_STATUS.ISSUED,
|
||||||
|
icon: FileCheckIcon,
|
||||||
|
color: "text-purple-600",
|
||||||
|
bgColor: "bg-purple-100",
|
||||||
|
borderColor: "border-purple-300",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ChangeStatusDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
proformas,
|
||||||
|
isSubmitting,
|
||||||
|
onConfirm,
|
||||||
|
}: ChangeStatusDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<PROFORMA_STATUS | null>(null);
|
||||||
|
|
||||||
|
// Obtener estados disponibles comunes para todas las proformas seleccionadas
|
||||||
|
const getAvailableStatuses = () => {
|
||||||
|
if (!proformas || proformas.length === 0) return [];
|
||||||
|
|
||||||
|
// Intersección de estados disponibles para todas las proformas
|
||||||
|
const firstProforma = proformas[0];
|
||||||
|
let availableStatuses =
|
||||||
|
PROFORMA_STATUS_TRANSITIONS[firstProforma.status as PROFORMA_STATUS] || [];
|
||||||
|
|
||||||
|
for (let i = 1; i < proformas.length; i++) {
|
||||||
|
const currentStatuses =
|
||||||
|
PROFORMA_STATUS_TRANSITIONS[proformas[i].status as PROFORMA_STATUS] || [];
|
||||||
|
availableStatuses = availableStatuses.filter((status) => currentStatuses.includes(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableStatuses;
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableStatuses = getAvailableStatuses();
|
||||||
|
|
||||||
|
const currentStatus = proformas.length === 1 ? proformas[0].status : null;
|
||||||
|
|
||||||
|
const getStatusType = (
|
||||||
|
status: PROFORMA_STATUS
|
||||||
|
): "past" | "current" | "available" | "unavailable" => {
|
||||||
|
if (currentStatus === status) return "current";
|
||||||
|
|
||||||
|
// Si no hay un estado actual único, solo mostrar disponibles
|
||||||
|
if (!currentStatus) {
|
||||||
|
return availableStatuses.includes(status) ? "available" : "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinar si es pasado basado en el flujo lógico
|
||||||
|
const currentIndex = STATUS_FLOW.findIndex((s) => s.status === currentStatus);
|
||||||
|
const statusIndex = STATUS_FLOW.findIndex((s) => s.status === status);
|
||||||
|
|
||||||
|
if (statusIndex < currentIndex) return "past";
|
||||||
|
if (availableStatuses.includes(status)) return "available";
|
||||||
|
return "unavailable";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Cambiar estado de la proforma</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{proformas.length === 1
|
||||||
|
? `Selecciona el nuevo estado para la proforma #${proformas[0].id}`
|
||||||
|
: `Selecciona el nuevo estado para ${proformas.length} proformas seleccionadas`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{availableStatuses.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No hay transiciones de estado disponibles para las proformas seleccionadas.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-6">
|
||||||
|
<div className="relative">
|
||||||
|
{/* Línea de conexión */}
|
||||||
|
<div className="absolute top-12 left-0 right-0 h-0.5 bg-gray-200" />
|
||||||
|
|
||||||
|
{/* Estados */}
|
||||||
|
<div className="relative grid grid-cols-5 gap-2">
|
||||||
|
{STATUS_FLOW.map((statusConfig, index) => {
|
||||||
|
const statusType = getStatusType(statusConfig.status);
|
||||||
|
const Icon = statusConfig.icon;
|
||||||
|
const isSelected = selectedStatus === statusConfig.status;
|
||||||
|
const isClickable = statusType === "available";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center" key={statusConfig.status}>
|
||||||
|
{/* Botón de estado */}
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex size-24 flex-col items-center justify-center rounded-xl border-2 transition-all",
|
||||||
|
// Estado pasado
|
||||||
|
statusType === "past" && ["border-gray-200 bg-gray-50", "opacity-60"],
|
||||||
|
// Estado actual
|
||||||
|
statusType === "current" && [
|
||||||
|
statusConfig.borderColor,
|
||||||
|
statusConfig.bgColor,
|
||||||
|
"ring-2 ring-offset-2",
|
||||||
|
`ring-${statusConfig.color.split("-")[1]}-200`,
|
||||||
|
],
|
||||||
|
// Estado disponible para seleccionar
|
||||||
|
statusType === "available" && [
|
||||||
|
isSelected
|
||||||
|
? [
|
||||||
|
statusConfig.borderColor,
|
||||||
|
statusConfig.bgColor,
|
||||||
|
"ring-2 ring-offset-2",
|
||||||
|
`ring-${statusConfig.color.split("-")[1]}-300`,
|
||||||
|
"scale-105",
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
"border-gray-300 bg-white hover:border-gray-400",
|
||||||
|
"hover:scale-105 cursor-pointer",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
// Estado no disponible
|
||||||
|
statusType === "unavailable" && [
|
||||||
|
"border-gray-200 bg-gray-50",
|
||||||
|
"opacity-40 cursor-not-allowed",
|
||||||
|
]
|
||||||
|
)}
|
||||||
|
disabled={!isClickable}
|
||||||
|
onClick={() => isClickable && setSelectedStatus(statusConfig.status)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"size-8 mb-1",
|
||||||
|
statusType === "past" && "text-gray-400",
|
||||||
|
statusType === "current" && statusConfig.color,
|
||||||
|
statusType === "available" &&
|
||||||
|
(isSelected ? statusConfig.color : "text-gray-600"),
|
||||||
|
statusType === "unavailable" && "text-gray-300"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Indicador de estado actual */}
|
||||||
|
{statusType === "current" && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute -top-2 -right-2 size-6 rounded-full border-2 border-white flex items-center justify-center",
|
||||||
|
statusConfig.bgColor
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"size-2 rounded-full",
|
||||||
|
statusConfig.color.replace("text-", "bg-")
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Check de selección */}
|
||||||
|
{isSelected && statusType !== "current" && (
|
||||||
|
<div className="absolute -top-2 -right-2 size-6 rounded-full bg-blue-600 border-2 border-white flex items-center justify-center">
|
||||||
|
<CheckCircle2Icon className="size-4 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Etiqueta y descripción */}
|
||||||
|
<div className="mt-3 text-center space-y-1 px-1">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
statusType === "past" && "text-gray-500",
|
||||||
|
statusType === "current" && statusConfig.color,
|
||||||
|
statusType === "available" &&
|
||||||
|
(isSelected ? statusConfig.color : "text-gray-700"),
|
||||||
|
statusType === "unavailable" && "text-gray-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(`catalog.proformas.status.${statusConfig.status}`)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{t(`catalog.proformas.status.${statusConfig.status}.description`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flecha de conexión (excepto el último) */}
|
||||||
|
{index < STATUS_FLOW.length - 1 && (
|
||||||
|
<ChevronRightIcon className="absolute top-10 -right-4 z-20 size-5 text-gray-300" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div className="flex items-start gap-6 text-sm">
|
||||||
|
{currentStatus && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="size-3 rounded-full bg-blue-600 ring-2 ring-blue-200" />
|
||||||
|
<span className="text-muted-foreground">Estado actual</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="size-3 rounded-full border-2 border-gray-400 bg-white" />
|
||||||
|
<span className="text-muted-foreground">Estados disponibles</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="size-3 rounded-full bg-gray-300" />
|
||||||
|
<span className="text-muted-foreground">Estados no disponibles</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!selectedStatus || availableStatuses.length === 0 || isSubmitting}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="mr-2 size-4" />
|
||||||
|
Cambiando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Cambiar estado"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./change-status-dialog";
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
import type { IDataSource } from "@erp/core/client";
|
||||||
|
|
||||||
|
export async function deleteProformaApi(
|
||||||
|
dataSource: IDataSource,
|
||||||
|
proformaId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await dataSource.deleteOne("proformas", proformaId);
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./delete-proforma.api";
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./use-delete-proforma-dialog-controller";
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../i18n";
|
||||||
|
import type { ProformaSummaryData } from "../../types";
|
||||||
|
import { useDeleteProforma } from "../hooks";
|
||||||
|
|
||||||
|
interface ProformaState {
|
||||||
|
open: boolean;
|
||||||
|
proforma: ProformaSummaryData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteProformaDialogController() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { mutate, isPending } = useDeleteProforma();
|
||||||
|
|
||||||
|
const [state, setState] = React.useState<ProformaState>({
|
||||||
|
open: false,
|
||||||
|
proforma: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const openDialog = (proforma: ProformaSummaryData) => {
|
||||||
|
setState({ open: true, proforma });
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setState((s) => ({ ...s, open: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (!state.proforma) return;
|
||||||
|
|
||||||
|
mutate(
|
||||||
|
{ proformaId: state.proforma.id },
|
||||||
|
{
|
||||||
|
onSuccess(data, variables, onMutateResult, context) {
|
||||||
|
console.log("adios");
|
||||||
|
console.log(data);
|
||||||
|
showSuccessToast(t("proformas.delete_proforma_dialog.success_title"));
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
onError(error, variables, onMutateResult, context) {
|
||||||
|
showErrorToast(t("proformas.delete_proforma_dialog.error_title"), error.message);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
open: state.open,
|
||||||
|
proforma: state.proforma,
|
||||||
|
isSubmitting: isPending,
|
||||||
|
|
||||||
|
openDialog,
|
||||||
|
closeDialog,
|
||||||
|
|
||||||
|
confirmDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./use-delete-proforma";
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { useDataSource } from "@erp/core/hooks";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { deleteProformaApi } from "../api";
|
||||||
|
|
||||||
|
interface useDeleteProformaPayload {
|
||||||
|
proformaId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteProforma() {
|
||||||
|
const dataSource = useDataSource();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ proformaId }: useDeleteProformaPayload) =>
|
||||||
|
deleteProformaApi(dataSource, proformaId),
|
||||||
|
|
||||||
|
onSuccess() {
|
||||||
|
console.log("hola");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["proformas"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./controllers";
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import { Trans } from "react-i18next";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../i18n";
|
||||||
|
|
||||||
|
interface DeleteProformaDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
proformaRef?: string;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteProformaDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
proformaRef,
|
||||||
|
isSubmitting,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteProformaDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("proformas.delete_proforma_dialog.title")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
<Trans
|
||||||
|
i18nKey={t("proformas.delete_proforma_dialog.description")}
|
||||||
|
values={{ proformaRef }}
|
||||||
|
/>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
|
||||||
|
{t("proformas.delete_proforma_dialog.cancel")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button disabled={isSubmitting} onClick={onConfirm} variant="destructive">
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="mr-2 size-4" />
|
||||||
|
{t("proformas.delete_proforma_dialog.deleting")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>{t("proformas.delete_proforma_dialog.delete")}</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./delete-proforma-dialog";
|
||||||
@ -1,4 +1,4 @@
|
|||||||
export * from "./use-proforma-items-columns";
|
export * from "./use-issue-proforma-invoice";
|
||||||
export * from "./use-proforma-query";
|
export * from "./use-proforma-query";
|
||||||
export * from "./use-proforma-update-mutation";
|
export * from "./use-proforma-update-mutation";
|
||||||
export * from "./use-proformas-query";
|
export * from "./use-proformas-query";
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import { useDataSource } from "@erp/core/hooks";
|
11111import
|
||||||
|
{
|
||||||
|
useDataSource;
|
||||||
|
}
|
||||||
|
from;
|
||||||
|
("@erp/core/hooks");
|
||||||
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import type { InvoiceFormData } from "../schemas";
|
import type { InvoiceFormData } from "../schemas";
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
// hooks/use-issue-proforma-invoice.ts
|
||||||
|
|
||||||
|
import { useDataSource } from "@erp/core/hooks";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const ISSUE_PROFORMA_INVOICE_KEY = ["proformas", "issue"] as const;
|
||||||
|
|
||||||
|
interface IssueProformaInvoicePayload {
|
||||||
|
proformaId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIssueProformaInvoice() {
|
||||||
|
const dataSource = useDataSource();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationKey: ISSUE_PROFORMA_INVOICE_KEY,
|
||||||
|
mutationFn: ({ proformaId }: IssueProformaInvoicePayload) =>
|
||||||
|
issueProformaInvoiceApi(dataSource, proformaId),
|
||||||
|
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["proformas"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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 "../schema/proforma.api.schema";
|
import type { Proforma } from "../types/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;
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useDataSource } from "@erp/core/hooks";
|
|||||||
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
|
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
|
||||||
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
|
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import type { ProformaSummaryPage } from "../schema/proforma.api.schema";
|
import type { ProformaSummaryPage } from "../types/proforma.api.schema";
|
||||||
|
|
||||||
export const PROFORMAS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [
|
export const PROFORMAS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [
|
||||||
"proforma",
|
"proforma",
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export * from "./pages";
|
export * from "./list";
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./issue-proforma-invoice.api";
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import type { IDataSource } from "@erp/core/client";
|
||||||
|
|
||||||
|
export interface IssueInvoiceResponse {
|
||||||
|
invoiceId: string;
|
||||||
|
proformaId: string;
|
||||||
|
customerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function issueProformaInvoiceApi(
|
||||||
|
dataSource: IDataSource,
|
||||||
|
proformaId: string
|
||||||
|
): Promise<IssueInvoiceResponse> {
|
||||||
|
return dataSource.custom<IssueInvoiceResponse>({
|
||||||
|
path: `proformas/${proformaId}/issue`,
|
||||||
|
method: "put",
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./use-issue-proforma-dialog.controller";
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import type { ProformaSummaryData } from "../../types";
|
||||||
|
import { useIssueProformaMutation } from "../hooks/use-issue-proforma-mutation";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
open: boolean;
|
||||||
|
proforma: ProformaSummaryData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProformaIssueDialogController() {
|
||||||
|
const { mutate, isPending } = useIssueProformaMutation();
|
||||||
|
|
||||||
|
const [state, setState] = React.useState<State>({
|
||||||
|
open: false,
|
||||||
|
proforma: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// abrir diálogo
|
||||||
|
const openDialog = (p: ProformaSummaryData) => {
|
||||||
|
setState({ open: true, proforma: p });
|
||||||
|
};
|
||||||
|
|
||||||
|
// cerrar diálogo
|
||||||
|
const closeDialog = () => {
|
||||||
|
setState((s) => ({ ...s, open: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// confirmar emisión
|
||||||
|
const confirmIssue = () => {
|
||||||
|
if (!state.proforma) return;
|
||||||
|
|
||||||
|
mutate(
|
||||||
|
{ proformaId: state.proforma.id },
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
open: state.open,
|
||||||
|
proforma: state.proforma,
|
||||||
|
isSubmitting: isPending,
|
||||||
|
|
||||||
|
openDialog,
|
||||||
|
closeDialog,
|
||||||
|
confirmIssue,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import { useDataSource } from "@erp/core/hooks";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { issueProformaInvoiceApi } from "../api";
|
||||||
|
|
||||||
|
interface IssueProformaPayload {
|
||||||
|
proformaId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssueProformaResponse {
|
||||||
|
proformaId: string;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIssueProformaMutation() {
|
||||||
|
const dataSource = useDataSource();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ proformaId }: IssueProformaPayload) =>
|
||||||
|
issueProformaInvoiceApi(dataSource, proformaId),
|
||||||
|
|
||||||
|
onSuccess(_data, _vars, _ctx) {
|
||||||
|
// Refresca el listado de proformas
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["proformas"] });
|
||||||
|
|
||||||
|
// Opcional: refrescar facturas si existe la feature
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./controllers";
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./issue-proforma-dialog";
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
|
||||||
|
import { useProformaIssueDialogController } from "../controllers";
|
||||||
|
|
||||||
|
interface ProformaIssueDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
proformaId: string;
|
||||||
|
proformaReference: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProformaIssueDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
proformaId,
|
||||||
|
proformaReference,
|
||||||
|
}: ProformaIssueDialogProps) {
|
||||||
|
const { issue, isSubmitting } = useProformaIssueDialogController();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Emitir factura</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
¿Seguro que quieres emitir la factura desde la proforma{" "}
|
||||||
|
<strong>{proformaReference}</strong>? Esta acción es irreversible.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => issue(proformaId, () => onOpenChange(false))}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="mr-2 size-4" />
|
||||||
|
Emitiendo...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Emitir factura"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./proforma-summary-dto.adapter";
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProformaSummaryData,
|
||||||
|
ProformaSummaryPage,
|
||||||
|
ProformaSummaryPageData,
|
||||||
|
} from "../../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte el DTO completo de API a datos numéricos para el formulario.
|
||||||
|
*/
|
||||||
|
export const ProformaSummaryDtoAdapter = {
|
||||||
|
fromDto(pageDto: ProformaSummaryPage, context?: unknown): ProformaSummaryPageData {
|
||||||
|
return {
|
||||||
|
...pageDto,
|
||||||
|
items: pageDto.items.map(
|
||||||
|
(summaryDto) =>
|
||||||
|
({
|
||||||
|
...summaryDto,
|
||||||
|
|
||||||
|
subtotal_amount: MoneyDTOHelper.toNumber(summaryDto.subtotal_amount),
|
||||||
|
subtotal_amount_fmt: formatCurrency(
|
||||||
|
MoneyDTOHelper.toNumber(summaryDto.subtotal_amount),
|
||||||
|
Number(summaryDto.total_amount.scale || 2),
|
||||||
|
summaryDto.currency_code,
|
||||||
|
summaryDto.language_code
|
||||||
|
),
|
||||||
|
|
||||||
|
discount_percentage: PercentageDTOHelper.toNumber(summaryDto.discount_percentage),
|
||||||
|
discount_percentage_fmt: PercentageDTOHelper.toNumericString(
|
||||||
|
summaryDto.discount_percentage
|
||||||
|
),
|
||||||
|
|
||||||
|
discount_amount: MoneyDTOHelper.toNumber(summaryDto.discount_amount),
|
||||||
|
discount_amount_fmt: formatCurrency(
|
||||||
|
MoneyDTOHelper.toNumber(summaryDto.discount_amount),
|
||||||
|
Number(summaryDto.total_amount.scale || 2),
|
||||||
|
summaryDto.currency_code,
|
||||||
|
summaryDto.language_code
|
||||||
|
),
|
||||||
|
|
||||||
|
taxable_amount: MoneyDTOHelper.toNumber(summaryDto.taxable_amount),
|
||||||
|
taxable_amount_fmt: formatCurrency(
|
||||||
|
MoneyDTOHelper.toNumber(summaryDto.taxable_amount),
|
||||||
|
Number(summaryDto.total_amount.scale || 2),
|
||||||
|
summaryDto.currency_code,
|
||||||
|
summaryDto.language_code
|
||||||
|
),
|
||||||
|
|
||||||
|
taxes_amount: MoneyDTOHelper.toNumber(summaryDto.taxes_amount),
|
||||||
|
taxes_amount_fmt: formatCurrency(
|
||||||
|
MoneyDTOHelper.toNumber(summaryDto.taxes_amount),
|
||||||
|
Number(summaryDto.total_amount.scale || 2),
|
||||||
|
summaryDto.currency_code,
|
||||||
|
summaryDto.language_code
|
||||||
|
),
|
||||||
|
|
||||||
|
total_amount: MoneyDTOHelper.toNumber(summaryDto.total_amount),
|
||||||
|
total_amount_fmt: formatCurrency(
|
||||||
|
MoneyDTOHelper.toNumber(summaryDto.total_amount),
|
||||||
|
Number(summaryDto.total_amount.scale || 2),
|
||||||
|
summaryDto.currency_code,
|
||||||
|
summaryDto.language_code
|
||||||
|
),
|
||||||
|
|
||||||
|
//taxes: dto.taxes,
|
||||||
|
}) as unknown as ProformaSummaryData
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import type { CriteriaDTO } from "@erp/core";
|
||||||
|
import type { IDataSource } from "@erp/core/client";
|
||||||
|
|
||||||
|
import type { ProformaSummaryPage } from "../../types";
|
||||||
|
|
||||||
|
export async function getProformaListApi(
|
||||||
|
dataSource: IDataSource,
|
||||||
|
signal: AbortSignal,
|
||||||
|
criteria: CriteriaDTO
|
||||||
|
) {
|
||||||
|
const response = dataSource.getList<ProformaSummaryPage>("proformas", {
|
||||||
|
signal,
|
||||||
|
...criteria,
|
||||||
|
});
|
||||||
|
|
||||||
|
//return mapProformaList(raw);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./get-proformas-list.api";
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./use-proforma-list.controller.ts";
|
||||||
|
export * from "./use-proforma-list-page.controller.ts";
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { useChangeStatusDialogController } from "../../change-status";
|
||||||
|
import { useDeleteProformaDialogController } from "../../delete";
|
||||||
|
import { useProformaIssueDialogController } from "../../issue-proforma";
|
||||||
|
import {
|
||||||
|
type PROFORMA_STATUS,
|
||||||
|
PROFORMA_STATUS_TRANSITIONS,
|
||||||
|
type ProformaSummaryData,
|
||||||
|
} from "../../types";
|
||||||
|
|
||||||
|
import { useProformaListController } from "./use-proforma-list.controller";
|
||||||
|
|
||||||
|
export function useProformaListPageController() {
|
||||||
|
const listCtrl = useProformaListController();
|
||||||
|
|
||||||
|
// Controlador de diálogos
|
||||||
|
const issueDialogCtrl = useProformaIssueDialogController();
|
||||||
|
const changeStatusDialogCtrl = useChangeStatusDialogController();
|
||||||
|
const deleteDialogCtrl = useDeleteProformaDialogController();
|
||||||
|
|
||||||
|
const handleIssueProforma = React.useCallback(
|
||||||
|
(proforma: ProformaSummaryData) => {
|
||||||
|
// Solo si approved → issued
|
||||||
|
issueDialogCtrl.openDialog(proforma);
|
||||||
|
},
|
||||||
|
[issueDialogCtrl]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChangeStatusProforma = React.useCallback(
|
||||||
|
(proforma: ProformaSummaryData, nextStatus: string) => {
|
||||||
|
const proforma_status = proforma.status as PROFORMA_STATUS;
|
||||||
|
const transitions = PROFORMA_STATUS_TRANSITIONS[proforma_status] ?? [];
|
||||||
|
|
||||||
|
if (!transitions.includes(nextStatus as PROFORMA_STATUS)) {
|
||||||
|
console.warn(`Transición inválida: ${proforma.status} → ${nextStatus}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeStatusDialogCtrl.openDialog(proforma, nextStatus);
|
||||||
|
},
|
||||||
|
[changeStatusDialogCtrl]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteProforma = React.useCallback(
|
||||||
|
(proforma: ProformaSummaryData) => {
|
||||||
|
console.log(proforma);
|
||||||
|
deleteDialogCtrl.openDialog(proforma);
|
||||||
|
},
|
||||||
|
[deleteDialogCtrl]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
listCtrl,
|
||||||
|
|
||||||
|
issueDialogCtrl,
|
||||||
|
changeStatusDialogCtrl,
|
||||||
|
deleteDialogCtrl,
|
||||||
|
|
||||||
|
handleIssueProforma,
|
||||||
|
handleChangeStatusProforma,
|
||||||
|
handleDeleteProforma,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,13 +1,11 @@
|
|||||||
// src/modules/proformas/hooks/use-proformas-list.ts
|
|
||||||
|
|
||||||
import type { CriteriaDTO } from "@erp/core";
|
import type { CriteriaDTO } from "@erp/core";
|
||||||
import { useDebounce } from "@repo/rdx-ui/components";
|
import { useDebounce } from "@repo/rdx-ui/components";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import { ProformaSummaryDtoAdapter } from "../../../adapters/proforma-summary-dto.adapter";
|
import { ProformaSummaryDtoAdapter } from "../adapters";
|
||||||
import { useProformasQuery } from "../../../hooks";
|
import { useProformaListQuery } from "../hooks";
|
||||||
|
|
||||||
export const useProformasList = () => {
|
export const useProformaListController = () => {
|
||||||
const [pageIndex, setPageIndex] = useState(0);
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@ -29,7 +27,7 @@ export const useProformasList = () => {
|
|||||||
};
|
};
|
||||||
}, [pageSize, pageIndex, debouncedQ, status]);
|
}, [pageSize, pageIndex, debouncedQ, status]);
|
||||||
|
|
||||||
const query = useProformasQuery({ criteria });
|
const query = useProformaListQuery({ criteria });
|
||||||
const data = useMemo(
|
const data = useMemo(
|
||||||
() => (query.data ? ProformaSummaryDtoAdapter.fromDto(query.data) : undefined),
|
() => (query.data ? ProformaSummaryDtoAdapter.fromDto(query.data) : undefined),
|
||||||
[query.data]
|
[query.data]
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./use-proforma-list-query";
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import type { CriteriaDTO } from "@erp/core";
|
||||||
|
import { useDataSource } from "@erp/core/hooks";
|
||||||
|
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
|
||||||
|
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type { ProformaSummaryPage } from "../../types";
|
||||||
|
import { getProformaListApi } from "../api";
|
||||||
|
|
||||||
|
export const PROFORMAS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [
|
||||||
|
"proforma",
|
||||||
|
{
|
||||||
|
pageNumber: criteria?.pageNumber ?? INITIAL_PAGE_INDEX,
|
||||||
|
pageSize: criteria?.pageSize ?? INITIAL_PAGE_SIZE,
|
||||||
|
q: criteria?.q ?? "",
|
||||||
|
filters: criteria?.filters ?? [],
|
||||||
|
orderBy: criteria?.orderBy ?? "",
|
||||||
|
order: criteria?.order ?? "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type ProformasQueryOptions = {
|
||||||
|
enabled?: boolean;
|
||||||
|
criteria?: CriteriaDTO;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener todas las facturas
|
||||||
|
export const useProformaListQuery = (options?: ProformasQueryOptions) => {
|
||||||
|
const dataSource = useDataSource();
|
||||||
|
const enabled = options?.enabled ?? true;
|
||||||
|
const criteria = options?.criteria ?? {};
|
||||||
|
|
||||||
|
return useQuery<ProformaSummaryPage, DefaultError>({
|
||||||
|
queryKey: PROFORMAS_QUERY_KEY(criteria),
|
||||||
|
queryFn: async ({ signal }) => getProformaListApi(dataSource, signal, criteria),
|
||||||
|
enabled,
|
||||||
|
placeholderData: (previousData, _previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./ui";
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
export * from "../../../issue-proforma/ui/issue-proforma-dialog";
|
||||||
|
|
||||||
|
export * from "./proformas-grid";
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./proformas-grid";
|
||||||
@ -1,27 +1,28 @@
|
|||||||
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
|
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../../i18n";
|
||||||
import type { ProformaSummaryPageData } from "../../../schema/proforma-summary.web.schema";
|
import type { ProformaSummaryData, ProformaSummaryPageData } from "../../../../types";
|
||||||
import { useProformasGridColumns } from "../hooks";
|
|
||||||
|
|
||||||
interface ProformasGridProps {
|
interface ProformasGridProps {
|
||||||
data: ProformaSummaryPageData;
|
data: ProformaSummaryPageData;
|
||||||
loading?: boolean;
|
loading: boolean;
|
||||||
|
|
||||||
|
columns: ColumnDef<ProformaSummaryData, unknown>[];
|
||||||
|
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
searchValue: string;
|
onPageChange: (pageIndex: number) => void;
|
||||||
onSearchChange: (v: string) => void;
|
onPageSizeChange: (size: number) => void;
|
||||||
onPageChange: (p: number) => void;
|
|
||||||
onPageSizeChange: (s: number) => void;
|
onRowClick?: (proformaId: string) => void;
|
||||||
onRowClick?: (id: string) => void;
|
|
||||||
onExportClick?: () => void;
|
|
||||||
onStatusFilterChange?: (newStatus: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProformasGrid = ({
|
export const ProformasGrid = ({
|
||||||
data,
|
data,
|
||||||
loading,
|
loading,
|
||||||
|
columns,
|
||||||
pageIndex,
|
pageIndex,
|
||||||
pageSize,
|
pageSize,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
@ -32,14 +33,6 @@ export const ProformasGrid = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { items, total_items } = data;
|
const { items, total_items } = data;
|
||||||
|
|
||||||
const columns = useProformasGridColumns({
|
|
||||||
onEdit: (proforma) => navigate(`/proformas/${proforma.id}/edit`),
|
|
||||||
onDuplicate: (proforma) => null, //duplicateInvoice(inv.id),
|
|
||||||
onDownloadPdf: (proforma) => null, //downloadInvoicePdf(inv.id),
|
|
||||||
onSendEmail: (proforma) => null, //sendInvoiceEmail(inv.id),
|
|
||||||
onDelete: (proforma) => null, //confirmDelete(inv.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (loading)
|
if (loading)
|
||||||
return (
|
return (
|
||||||
<SkeletonDataTable
|
<SkeletonDataTable
|
||||||
@ -55,6 +48,7 @@ export const ProformasGrid = ({
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
data={items}
|
data={items}
|
||||||
enablePagination
|
enablePagination
|
||||||
|
enableRowSelection
|
||||||
manualPagination
|
manualPagination
|
||||||
onPageChange={onPageChange}
|
onPageChange={onPageChange}
|
||||||
onPageSizeChange={onPageSizeChange}
|
onPageSizeChange={onPageSizeChange}
|
||||||
@ -17,25 +17,27 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../../i18n";
|
||||||
import type { ProformaSummaryData } from "../../../schema";
|
import {
|
||||||
import { ProformaStatusBadge } from "../ui";
|
PROFORMA_STATUS_TRANSITIONS,
|
||||||
|
type ProformaStatus,
|
||||||
|
type ProformaSummaryData,
|
||||||
|
} from "../../../../types";
|
||||||
|
import { ProformaStatusBadge } from "../../components";
|
||||||
|
|
||||||
type GridActionHandlers = {
|
type GridActionHandlers = {
|
||||||
onEdit?: (proforma: ProformaSummaryData) => void;
|
onEditClick?: (proforma: ProformaSummaryData) => void;
|
||||||
onDuplicate?: (proforma: ProformaSummaryData) => void;
|
onIssueClick?: (proforma: ProformaSummaryData) => void;
|
||||||
onDownloadPdf?: (proforma: ProformaSummaryData) => void;
|
onChangeStatusClick?: (proforma: ProformaSummaryData, nextStatus: string) => void;
|
||||||
onSendEmail?: (proforma: ProformaSummaryData) => void;
|
onDeleteClick?: (proforma: ProformaSummaryData) => void;
|
||||||
onDelete?: (proforma: ProformaSummaryData) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useProformasGridColumns(
|
export function useProformasGridColumns(
|
||||||
actionHandlers: GridActionHandlers = {}
|
actionHandlers: GridActionHandlers = {}
|
||||||
): ColumnDef<ProformaSummaryData, unknown>[] {
|
): ColumnDef<ProformaSummaryData, unknown>[] {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete } = actionHandlers;
|
|
||||||
|
|
||||||
return React.useMemo<ColumnDef<ProformaSummaryData>[]>(
|
return React.useMemo<ColumnDef<ProformaSummaryData, unknown>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
@ -75,29 +77,34 @@ export function useProformasGridColumns(
|
|||||||
},
|
},
|
||||||
cell: ({ row }) => <div className="font-medium">{row.getValue("invoice_number")}</div>,
|
cell: ({ row }) => <div className="font-medium">{row.getValue("invoice_number")}</div>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Estado
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
header: "Estado",
|
header: "Estado",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const status = String(row.getValue("status"));
|
|
||||||
const isIssued = status === "issued";
|
|
||||||
const proforma = row.original;
|
const proforma = row.original;
|
||||||
|
|
||||||
|
const isIssued = proforma.status === "issued";
|
||||||
|
const invoiceId = proforma.linked_invoice_id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ProformaStatusBadge status={status} />
|
<ProformaStatusBadge status={proforma.status} />
|
||||||
{isIssued && proforma.id && (
|
|
||||||
|
{/* Enlace discreto a factura real */}
|
||||||
|
{isIssued && invoiceId && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button asChild className="size-6" size="icon" variant="ghost">
|
<Button asChild className="size-6" size="icon" variant="ghost">
|
||||||
<a href={`/facturas/${proforma.id}`}>
|
<a href={`/facturas/${invoiceId}`}>
|
||||||
<ExternalLinkIcon className="size-3 text-muted-foreground" />
|
<ExternalLinkIcon className="size-3 text-muted-foreground" />
|
||||||
<span className="sr-only">Ver factura #{proforma.id}</span>
|
<span className="sr-only">Ver factura {invoiceId}</span>
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Ver factura #{proforma.id}</TooltipContent>
|
<TooltipContent>Ver factura {invoiceId}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
@ -105,6 +112,8 @@ export function useProformasGridColumns(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Cliente
|
||||||
{
|
{
|
||||||
accessorKey: "client_name",
|
accessorKey: "client_name",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
@ -124,7 +133,7 @@ export function useProformasGridColumns(
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<a
|
<a
|
||||||
className="text-blue-600 hover:underline"
|
className="text-primary hover:underline"
|
||||||
href={`/customers/${proforma.customer_id}`}
|
href={`/customers/${proforma.customer_id}`}
|
||||||
>
|
>
|
||||||
{proforma.recipient.name}
|
{proforma.recipient.name}
|
||||||
@ -205,88 +214,105 @@ export function useProformasGridColumns(
|
|||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Acciones",
|
header: "Acciones",
|
||||||
|
enableSorting: false,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const proforma = row.original;
|
const proforma = row.original;
|
||||||
const isIssued = proforma.status === "issued";
|
const isIssued = proforma.status === "issued";
|
||||||
const isApproved = proforma.status === "approved";
|
const isApproved = proforma.status === "approved";
|
||||||
|
const availableTransitions =
|
||||||
|
PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button asChild className="size-8" size="icon" variant="ghost">
|
|
||||||
<a href={`/proformas/${proforma.id}`}>
|
|
||||||
<PencilIcon className="size-4" />
|
|
||||||
<span className="sr-only">Editar</span>
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Editar</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
{!isIssued && (
|
{!isIssued && (
|
||||||
<>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className="size-8"
|
className="size-8 cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => actionHandlers.onEditClick?.(proforma)}
|
||||||
row.toggleSelected(true);
|
|
||||||
//setChangeStatusOpen(true);
|
|
||||||
}}
|
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className="size-4" />
|
<PencilIcon className="size-4" />
|
||||||
<span className="sr-only">Cambiar estado</span>
|
<span className="sr-only">Editar</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Cambiar estado</TooltipContent>
|
<TooltipContent>Editar</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
{isApproved && (
|
{/* Cambiar estado */}
|
||||||
|
{!isIssued &&
|
||||||
|
availableTransitions.map((next_status) => (
|
||||||
|
<TooltipProvider key={next_status}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className="size-8"
|
className="size-8 cursor-pointer"
|
||||||
onClick={() => null /*handleIssueInvoice(proforma)*/}
|
onClick={() =>
|
||||||
|
actionHandlers.onChangeStatusClick?.(proforma, next_status)
|
||||||
|
}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<FileTextIcon className="size-4" />
|
<RefreshCwIcon className="size-4" />
|
||||||
<span className="sr-only">Emitir a factura</span>
|
<span className="sr-only">Cambiar estado</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Emitir a factura</TooltipContent>
|
<TooltipContent>
|
||||||
|
Cambiar a {t(`catalog.proformas.status.${next_status}`)}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
</TooltipProvider>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Emitir factura: solo si approved */}
|
||||||
|
{!isIssued && proforma.status === "approved" && (
|
||||||
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className="size-8 text-destructive hover:text-destructive"
|
className="size-8 cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => actionHandlers.onIssueClick?.(proforma)}
|
||||||
//setProformaToDelete(proforma.id);
|
size="icon"
|
||||||
//setDeleteDialogOpen(true);
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<FileTextIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Emitir a factura</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Eliminar */}
|
||||||
|
{!isIssued && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="size-8 text-destructive hover:text-destructive cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
actionHandlers.onDeleteClick?.(proforma);
|
||||||
}}
|
}}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Trash2Icon className="size-4" />
|
<Trash2Icon className="size-4" />
|
||||||
<span className="sr-only">Eliminar</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Eliminar</TooltipContent>
|
<TooltipContent>Eliminar</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete]
|
[t, actionHandlers]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,2 +1 @@
|
|||||||
export * from "./proforma-status-badge";
|
export * from "./proforma-status-badge";
|
||||||
export * from "./proformas-grid";
|
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { Badge } from "@repo/shadcn-ui/components";
|
||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import {
|
||||||
|
type ProformaStatus,
|
||||||
|
getProformaStatusButtonVariant,
|
||||||
|
getProformaStatusColor,
|
||||||
|
} from "../../../types";
|
||||||
|
|
||||||
|
export type ProformaStatusBadgeProps = {
|
||||||
|
status: string | ProformaStatus; // permitir cualquier valor
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgeProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const normalizedStatus = status.toLowerCase() as ProformaStatus;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className={cn(getProformaStatusColor(normalizedStatus), "font-semibold", className)}
|
||||||
|
variant={getProformaStatusButtonVariant(normalizedStatus)}
|
||||||
|
>
|
||||||
|
{t(`catalog.proformas.status.${normalizedStatus}`, { defaultValue: status })}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ProformaStatusBadge.displayName = "ProformaStatusBadge";
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./pages";
|
||||||
@ -0,0 +1,148 @@
|
|||||||
|
import { PageHeader, SimpleSearchInput } from "@erp/core/components";
|
||||||
|
import { ErrorAlert } from "@erp/customers/components";
|
||||||
|
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import { FilterIcon, PlusIcon } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import { ChangeStatusDialog } from "../../../change-status";
|
||||||
|
import { DeleteProformaDialog } from "../../../delete/ui";
|
||||||
|
import { useProformaListPageController } from "../../controllers";
|
||||||
|
import { ProformaIssueDialog } from "../blocks";
|
||||||
|
import { ProformasGrid } from "../blocks/proformas-grid";
|
||||||
|
import { useProformasGridColumns } from "../blocks/proformas-grid/use-proforma-grid-columns";
|
||||||
|
|
||||||
|
export const ProformaListPage = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const {
|
||||||
|
listCtrl,
|
||||||
|
|
||||||
|
issueDialogCtrl,
|
||||||
|
changeStatusDialogCtrl,
|
||||||
|
deleteDialogCtrl,
|
||||||
|
|
||||||
|
handleChangeStatusProforma,
|
||||||
|
handleDeleteProforma,
|
||||||
|
handleIssueProforma,
|
||||||
|
} = useProformaListPageController();
|
||||||
|
|
||||||
|
const columns = useProformasGridColumns({
|
||||||
|
onEditClick: (proforma) => navigate(`/proformas/${proforma.id}/edit`),
|
||||||
|
onIssueClick: handleIssueProforma,
|
||||||
|
onDeleteClick: handleDeleteProforma,
|
||||||
|
onChangeStatusClick: handleChangeStatusProforma,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (listCtrl.isError || !listCtrl.data) {
|
||||||
|
return (
|
||||||
|
<AppContent>
|
||||||
|
<ErrorAlert
|
||||||
|
message={(listCtrl.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={
|
||||||
|
<Button
|
||||||
|
aria-label={t("pages.proformas.create.title")}
|
||||||
|
onClick={() => navigate("/proformas/create")}
|
||||||
|
>
|
||||||
|
<PlusIcon aria-hidden className="mr-2 size-4" />
|
||||||
|
{t("pages.proformas.create.title")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title={t("pages.proformas.list.title")}
|
||||||
|
/>
|
||||||
|
</AppHeader>
|
||||||
|
<AppContent>
|
||||||
|
{/* Search and filters */}
|
||||||
|
<div className="flex items-center justify-between gap-16">
|
||||||
|
<SimpleSearchInput
|
||||||
|
loading={listCtrl.isLoading}
|
||||||
|
onSearchChange={listCtrl.setSearchValue}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select defaultValue="all" onValueChange={listCtrl.setStatusFilter}>
|
||||||
|
<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("catalog.proformas.status.all")}</SelectItem>
|
||||||
|
<SelectItem value="draft">{t("catalog.proformas.status.draft")}</SelectItem>
|
||||||
|
<SelectItem value="sent">{t("catalog.proformas.status.sent")}</SelectItem>
|
||||||
|
<SelectItem value="approved">{t("catalog.proformas.status.approved")}</SelectItem>
|
||||||
|
<SelectItem value="rejected">{t("catalog.proformas.status.rejected")}</SelectItem>
|
||||||
|
<SelectItem value="issued">{t("catalog.proformas.status.issued")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProformasGrid
|
||||||
|
columns={columns}
|
||||||
|
data={listCtrl.data}
|
||||||
|
loading={listCtrl.isLoading}
|
||||||
|
onChangeStatusClick={handleChangeStatusProforma}
|
||||||
|
onDeleteClick={handleDeleteProforma}
|
||||||
|
onIssueClick={handleIssueProforma}
|
||||||
|
onPageChange={listCtrl.setPageIndex}
|
||||||
|
onPageSizeChange={listCtrl.setPageSize}
|
||||||
|
// acciones rápidas del grid → page controller
|
||||||
|
//onRowClick={(id) => navigate(`/proformas/${id}`)}
|
||||||
|
pageIndex={listCtrl.pageIndex}
|
||||||
|
pageSize={listCtrl.pageSize}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Emitir factura */}
|
||||||
|
<ProformaIssueDialog
|
||||||
|
isSubmitting={issueDialogCtrl.isSubmitting}
|
||||||
|
onConfirm={issueDialogCtrl.confirmIssue}
|
||||||
|
onOpenChange={(open) => !open && issueDialogCtrl.closeDialog()}
|
||||||
|
open={issueDialogCtrl.open}
|
||||||
|
proformaId={issueDialogCtrl.proforma?.id ?? 0}
|
||||||
|
proformaReference={issueDialogCtrl.proforma?.reference ?? ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cambiar estado */}
|
||||||
|
<ChangeStatusDialog
|
||||||
|
isSubmitting={changeStatusDialogCtrl.isSubmitting}
|
||||||
|
onConfirm={changeStatusDialogCtrl.confirmChangeStatus}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) changeStatusDialogCtrl.closeDialog();
|
||||||
|
}}
|
||||||
|
open={changeStatusDialogCtrl.open}
|
||||||
|
proformaRef={changeStatusDialogCtrl.proforma?.reference}
|
||||||
|
proformas={changeStatusDialogCtrl.proformas}
|
||||||
|
targetStatus={changeStatusDialogCtrl.targetStatus ?? undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Eliminar */}
|
||||||
|
<DeleteProformaDialog
|
||||||
|
isSubmitting={deleteDialogCtrl.isSubmitting}
|
||||||
|
onConfirm={deleteDialogCtrl.confirmDelete}
|
||||||
|
onOpenChange={(o) => !o && deleteDialogCtrl.closeDialog()}
|
||||||
|
open={deleteDialogCtrl.open}
|
||||||
|
proformaRef={deleteDialogCtrl.proforma?.reference}
|
||||||
|
/>
|
||||||
|
</AppContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,2 +0,0 @@
|
|||||||
export * from "./use-proformas-grid-columns";
|
|
||||||
export * from "./use-proformas-list";
|
|
||||||
@ -1,513 +0,0 @@
|
|||||||
import { formatDate } from "@erp/core/client";
|
|
||||||
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
|
||||||
Checkbox,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@repo/shadcn-ui/components";
|
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
|
||||||
import {
|
|
||||||
ArrowBigRightDashIcon,
|
|
||||||
CopyIcon,
|
|
||||||
DownloadIcon,
|
|
||||||
EditIcon,
|
|
||||||
ExternalLinkIcon,
|
|
||||||
MailIcon,
|
|
||||||
MoreVerticalIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
|
||||||
import type { ProformaSummaryData } from "../../../schema";
|
|
||||||
import { ProformaStatusBadge } from "../ui";
|
|
||||||
|
|
||||||
type GridActionHandlers = {
|
|
||||||
onEdit?: (proforma: ProformaSummaryData) => void;
|
|
||||||
onDuplicate?: (proforma: ProformaSummaryData) => void;
|
|
||||||
onDownloadPdf?: (proforma: ProformaSummaryData) => void;
|
|
||||||
onSendEmail?: (proforma: ProformaSummaryData) => void;
|
|
||||||
onDelete?: (proforma: ProformaSummaryData) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useProformasGridColumns(
|
|
||||||
actionHandlers: GridActionHandlers = {}
|
|
||||||
): ColumnDef<ProformaSummaryData, unknown>[] {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete } = actionHandlers;
|
|
||||||
|
|
||||||
return React.useMemo<ColumnDef<ProformaSummaryData>[]>(
|
|
||||||
() => [
|
|
||||||
// Select
|
|
||||||
{
|
|
||||||
id: "select",
|
|
||||||
header: ({ table }) => (
|
|
||||||
<Checkbox
|
|
||||||
aria-label="Seleccionar todo"
|
|
||||||
checked={
|
|
||||||
table.getIsAllPageRowsSelected() ||
|
|
||||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
|
||||||
}
|
|
||||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Checkbox
|
|
||||||
aria-label="Seleccionar fila"
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Nº
|
|
||||||
{
|
|
||||||
accessorKey: "invoice_number",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader
|
|
||||||
className="text-left tabular-nums justify-end"
|
|
||||||
column={column}
|
|
||||||
title={t("pages.proformas.list.grid_columns.invoice_number")}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="font-semibold tabular-nums">{row.getValue("invoice_number")}</div>
|
|
||||||
),
|
|
||||||
enableHiding: false,
|
|
||||||
meta: {
|
|
||||||
title: t("pages.proformas.list.grid_columns.invoice_number"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Estado
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader
|
|
||||||
className="text-left"
|
|
||||||
column={column}
|
|
||||||
title={t("pages.proformas.list.grid_columns.status")}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ProformaStatusBadge status={row.original.status} />
|
|
||||||
{row.original.status === "issued" && (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button asChild className="size-6" size="icon" variant="ghost">
|
|
||||||
<a href={`/facturas/${row.original.issued_invoice_id}`}>
|
|
||||||
<ExternalLinkIcon className="size-4 text-foreground" />
|
|
||||||
<span className="sr-only">
|
|
||||||
Ver factura #{row.original.issued_invoice_id}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Ver factura #{row.original.issued_invoice_id}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
size: 64,
|
|
||||||
minSize: 64,
|
|
||||||
meta: {
|
|
||||||
title: t("pages.proformas.list.grid_columns.status"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "recipient",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader
|
|
||||||
className="text-left"
|
|
||||||
column={column}
|
|
||||||
title={t("pages.proformas.list.grid_columns.recipient")}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
accessorFn: (row) => row.recipient.name, // para ordenar/buscar por nombre
|
|
||||||
enableHiding: false,
|
|
||||||
minSize: 120,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const c = row.original.recipient;
|
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-1">
|
|
||||||
<div className="min-w-0 grid gap-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="font-semibold truncate text-primary">{c.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{c.tin && <span className="text-xs text-muted-foreground truncate">{c.tin}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
title: t("pages.proformas.list.grid_columns.recipient"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Serie
|
|
||||||
{
|
|
||||||
accessorKey: "series",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader
|
|
||||||
className="text-left"
|
|
||||||
column={column}
|
|
||||||
title={t("pages.proformas.list.grid_columns.series")}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <div className="font-normal text-left">{row.original.series}</div>,
|
|
||||||
enableSorting: false,
|
|
||||||
size: 64,
|
|
||||||
minSize: 64,
|
|
||||||
meta: {
|
|
||||||
title: t("pages.proformas.list.grid_columns.series"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Referencia
|
|
||||||
{
|
|
||||||
accessorKey: "reference",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader
|
|
||||||
className="text-left"
|
|
||||||
column={column}
|
|
||||||
title={t("pages.proformas.list.grid_columns.reference")}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <div className="font-medium text-left">{row.original.reference}</div>,
|
|
||||||
enableSorting: false,
|
|
||||||
size: 120,
|
|
||||||
minSize: 100,
|
|
||||||
meta: {
|
|
||||||
title: t("pages.proformas.list.grid_columns.reference"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Fecha factura
|
|
||||||
{
|
|
||||||
accessorKey: "invoice_date",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader
|
|
||||||
className="text-left tabular-nums"
|
|
||||||
column={column}
|
|
||||||
title={t("pages.proformas.list.grid_columns.invoice_date")}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="font-medium text-left tabular-nums">
|
|
||||||
{formatDate(row.original.invoice_date)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
size: 96,
|
|
||||||
minSize: 96,
|
|
||||||
meta: {
|
|
||||||
title: t("pages.proformas.list.grid_columns.invoice_date"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Fecha operación
|
|
||||||
{
|
|
||||||
accessorKey: "operation_date",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader
|
|
||||||
className="text-left tabular-nums"
|
|
||||||
column={column}
|
|
||||||
title={t("pages.proformas.list.grid_columns.operation_date")}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="font-medium text-left tabular-nums">
|
|
||||||
{formatDate(row.original.operation_date)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
size: 96,
|
|
||||||
minSize: 96,
|
|
||||||
meta: {
|
|
||||||
title: t("pages.proformas.list.grid_columns.operation_date"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Subtotal amount
|
|
||||||
{
|
|
||||||
accessorKey: "subtotal_amount_fmt",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader
|
|
||||||
className="text-right tabular-nums"
|
|
||||||
column={column}
|
|
||||||
title={t("pages.proformas.list.grid_columns.subtotal_amount")}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="font-medium text-right tabular-nums">
|
|
||||||
{row.original.subtotal_amount_fmt}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
size: 120,
|
|
||||||
minSize: 100,
|
|
||||||
meta: {
|
|
||||||
title: t("pages.proformas.list.grid_columns.subtotal_amount"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Discount amount
|
|
||||||
{
|
|
||||||
accessorKey: "discount_amount_fmt",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader
|
|
||||||
className="text-right tabular-nums"
|
|
||||||
column={column}
|
|
||||||
title={t("pages.proformas.list.grid_columns.discount_amount")}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="font-medium text-right tabular-nums">
|
|
||||||
{row.original.discount_amount_fmt}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
size: 120,
|
|
||||||
minSize: 100,
|
|
||||||
meta: {
|
|
||||||
title: t("pages.proformas.list.grid_columns.discount_amount"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Taxes amount
|
|
||||||
{
|
|
||||||
accessorKey: "taxes_amount_fmt",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader
|
|
||||||
className="text-right tabular-nums"
|
|
||||||
column={column}
|
|
||||||
title={t("pages.proformas.list.grid_columns.taxes_amount")}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="font-medium text-right tabular-nums">{row.original.taxes_amount_fmt}</div>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
size: 120,
|
|
||||||
minSize: 100,
|
|
||||||
meta: {
|
|
||||||
title: t("pages.proformas.list.grid_columns.taxes_amount"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Total amount
|
|
||||||
{
|
|
||||||
accessorKey: "total_amount_fmt",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader
|
|
||||||
className="text-right tabular-nums"
|
|
||||||
column={column}
|
|
||||||
title={t("pages.proformas.list.grid_columns.total_amount")}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="font-semibold text-right tabular-nums">
|
|
||||||
{row.original.total_amount_fmt}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
size: 140,
|
|
||||||
minSize: 120,
|
|
||||||
meta: {
|
|
||||||
title: t("pages.proformas.list.grid_columns.total_amount"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ─────────────────────────────
|
|
||||||
// Acciones
|
|
||||||
// ─────────────────────────────
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader
|
|
||||||
className="text-left"
|
|
||||||
column={column}
|
|
||||||
title={t("common.actions")}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
|
||||||
size: 110,
|
|
||||||
minSize: 96,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const proforma = row.original;
|
|
||||||
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ButtonGroup>
|
|
||||||
{/* Emitir factura: approved -> issued */}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
aria-label={t("common.edit_row")}
|
|
||||||
className="cursor-pointer text-muted-foreground hover:text-primary"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onEdit?.(proforma);
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<ArrowBigRightDashIcon aria-hidden="true" className="size-4 " />
|
|
||||||
<span className="sr-only">Emitir</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{t("common.edit_row")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Editar (acción primaria) */}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
aria-label={t("common.edit_row")}
|
|
||||||
className="cursor-pointer text-muted-foreground hover:text-primary hidden"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onEdit?.(proforma);
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<EditIcon aria-hidden="true" className="size-4 " />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{t("common.edit_row")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Duplicar */}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
aria-label={t("common.duplicate_row")}
|
|
||||||
className="cursor-pointer text-muted-foreground hover:text-primary hidden"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDuplicate?.(proforma);
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<CopyIcon aria-hidden="true" className="size-4 " />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{t("common.duplicate_row")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Descargar en PDF */}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
aria-label={t("common.download_pdf")}
|
|
||||||
className="cursor-pointer text-muted-foreground hover:text-primary hidden"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDownloadPdf?.(proforma);
|
|
||||||
}}
|
|
||||||
size="icon-sm"
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<DownloadIcon aria-hidden="true" className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{t("common.download_pdf")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
aria-label={t("common.delete_row")}
|
|
||||||
className="cursor-pointer text-destructive hover:bg-destructive/90 hover:text-white"
|
|
||||||
onClick={() => onDelete?.(proforma)}
|
|
||||||
size="icon-sm"
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<Trash2Icon aria-hidden="true" className="size-4" />
|
|
||||||
<span className="sr-only">{t("common.delete_row")}</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{t("common.delete_row")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Menú demás acciones */}
|
|
||||||
{/** biome-ignore lint/suspicious/noSelfCompare: <Desactivado por ahora> */}
|
|
||||||
{false !== false && (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
aria-label={t("common.more_actions")}
|
|
||||||
className="cursor-pointer text-muted-foreground hover:text-primary"
|
|
||||||
onClick={stop}
|
|
||||||
size="sm"
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<MoreVerticalIcon aria-hidden="true" className="size-4" />
|
|
||||||
<span className="sr-only">{t("common.more_actions")}</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => onDuplicate?.(proforma)}
|
|
||||||
>
|
|
||||||
<CopyIcon className="mr-2 size-4" />
|
|
||||||
{t("common.duplicate_row")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => onDownloadPdf?.(proforma)}
|
|
||||||
>
|
|
||||||
<DownloadIcon className="mr-2 size-4" />
|
|
||||||
{t("common.download_pdf")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => onSendEmail?.(proforma)}
|
|
||||||
>
|
|
||||||
<MailIcon className="mr-2 size-4" />
|
|
||||||
{t("common.send_email")}
|
|
||||||
</DropdownMenuItem>{" "}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive focus:text-destructive-foreground focus:bg-destructive cursor-pointer"
|
|
||||||
onClick={() => onDelete?.(proforma)}
|
|
||||||
>
|
|
||||||
<Trash2Icon className="mr-2 size-4 text-destructive focus:text-destructive-foreground" />
|
|
||||||
{t("common.delete_row")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</ButtonGroup>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
title: t("common.actions"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[t, onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
import { PageHeader, SimpleSearchInput } from "@erp/core/components";
|
|
||||||
import { ErrorAlert } from "@erp/customers/components";
|
|
||||||
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@repo/shadcn-ui/components";
|
|
||||||
import { FilterIcon, PlusIcon } from "lucide-react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
|
||||||
|
|
||||||
import { useProformasList } from "./hooks";
|
|
||||||
import { ProformasGrid } from "./ui";
|
|
||||||
|
|
||||||
export const ProformaListPage = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const list = useProformasList();
|
|
||||||
|
|
||||||
if (list.isError || !list.data) {
|
|
||||||
return (
|
|
||||||
<AppContent>
|
|
||||||
<ErrorAlert
|
|
||||||
message={(list.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={
|
|
||||||
<Button
|
|
||||||
aria-label={t("pages.proformas.create.title")}
|
|
||||||
onClick={() => navigate("/proformas/create")}
|
|
||||||
>
|
|
||||||
<PlusIcon aria-hidden className="mr-2 size-4" />
|
|
||||||
{t("pages.proformas.create.title")}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
title={t("pages.proformas.list.title")}
|
|
||||||
/>
|
|
||||||
</AppHeader>
|
|
||||||
<AppContent>
|
|
||||||
{/* Search and filters */}
|
|
||||||
<div className="flex items-center justify-between gap-16">
|
|
||||||
<SimpleSearchInput loading={list.isLoading} onSearchChange={list.setSearchValue} />
|
|
||||||
|
|
||||||
<Select defaultValue="all" onValueChange={list.setStatusFilter}>
|
|
||||||
<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("catalog.proformas.status.all")}</SelectItem>
|
|
||||||
<SelectItem value="draft">{t("catalog.proformas.status.draft")}</SelectItem>
|
|
||||||
<SelectItem value="sent">{t("catalog.proformas.status.sent")}</SelectItem>
|
|
||||||
<SelectItem value="approved">{t("catalog.proformas.status.approved")}</SelectItem>
|
|
||||||
<SelectItem value="rejected">{t("catalog.proformas.status.rejected")}</SelectItem>
|
|
||||||
<SelectItem value="issued">{t("catalog.proformas.status.issued")}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProformasGrid
|
|
||||||
data={list.data}
|
|
||||||
loading={list.isLoading}
|
|
||||||
onPageChange={list.setPageIndex}
|
|
||||||
onPageSizeChange={list.setPageSize}
|
|
||||||
onSearchChange={list.setSearchValue}
|
|
||||||
onStatusFilterChange={list.setStatusFilter}
|
|
||||||
pageIndex={list.pageIndex}
|
|
||||||
pageSize={list.pageSize}
|
|
||||||
searchValue={list.search}
|
|
||||||
/>
|
|
||||||
</AppContent>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
import { Badge } from "@repo/shadcn-ui/components";
|
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
|
||||||
|
|
||||||
export type ProformaStatus = "draft" | "sent" | "approved" | "rejected" | "issued";
|
|
||||||
|
|
||||||
export type ProformaStatusBadgeProps = {
|
|
||||||
status: string | ProformaStatus; // permitir cualquier valor
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgeProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const normalizedStatus = status.toLowerCase() as ProformaStatus;
|
|
||||||
|
|
||||||
const getVariant = (
|
|
||||||
status: ProformaStatus
|
|
||||||
): "default" | "secondary" | "outline" | "destructive" => {
|
|
||||||
switch (status) {
|
|
||||||
case "draft":
|
|
||||||
return "outline";
|
|
||||||
case "sent":
|
|
||||||
return "secondary";
|
|
||||||
case "approved":
|
|
||||||
return "default";
|
|
||||||
case "rejected":
|
|
||||||
return "destructive";
|
|
||||||
case "issued":
|
|
||||||
return "default";
|
|
||||||
default:
|
|
||||||
return "outline";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getColor = (status: ProformaStatus): string => {
|
|
||||||
switch (status) {
|
|
||||||
case "draft":
|
|
||||||
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
|
|
||||||
case "sent":
|
|
||||||
return "bg-yellow-100 text-yellow-700 hover:bg-yellow-100";
|
|
||||||
case "approved":
|
|
||||||
return "bg-green-100 text-green-700 hover:bg-green-100";
|
|
||||||
case "rejected":
|
|
||||||
return "bg-red-100 text-red-700 hover:bg-red-100";
|
|
||||||
case "issued":
|
|
||||||
return "bg-blue-100 text-blue-700 hover:bg-blue-100";
|
|
||||||
default:
|
|
||||||
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
className={cn(getColor(normalizedStatus), "font-semibold", className)}
|
|
||||||
variant={getVariant(normalizedStatus)}
|
|
||||||
>
|
|
||||||
{t(`catalog.proformas.status.${normalizedStatus}`, { defaultValue: status })}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ProformaStatusBadge.displayName = "ProformaStatusBadge";
|
|
||||||
@ -14,7 +14,7 @@ import {
|
|||||||
type ProformaFormData,
|
type ProformaFormData,
|
||||||
ProformaFormSchema,
|
ProformaFormSchema,
|
||||||
defaultProformaFormData,
|
defaultProformaFormData,
|
||||||
} from "../../schema";
|
} from "../../types";
|
||||||
|
|
||||||
import { useProformaContext } from "./context";
|
import { useProformaContext } from "./context";
|
||||||
import { ProformaUpdateForm } from "./proforma-update-form";
|
import { ProformaUpdateForm } from "./proforma-update-form";
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { FormDebug } from "@erp/core/components";
|
|||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import { type FieldErrors, useFormContext } from "react-hook-form";
|
import { type FieldErrors, useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import type { ProformaFormData } from "../../schema";
|
import type { ProformaFormData } from "../../types";
|
||||||
|
|
||||||
import { ProformaBasicInfoFields, ProformaItems, ProformaRecipient, ProformaTotals } from "./ui";
|
import { ProformaBasicInfoFields, ProformaItems, ProformaRecipient, ProformaTotals } from "./ui";
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import type { ComponentProps } from "react";
|
|||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../../i18n";
|
import { useTranslation } from "../../../../../i18n";
|
||||||
import type { ProformaFormData } from "../../../../schema";
|
import type { ProformaFormData } from "../../../../types";
|
||||||
|
|
||||||
export const ProformaBasicInfoFields = (props: ComponentProps<"fieldset">) => {
|
export const ProformaBasicInfoFields = (props: ComponentProps<"fieldset">) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import type { ComponentProps } from "react";
|
|||||||
import { useFormContext, useWatch } from "react-hook-form";
|
import { useFormContext, useWatch } from "react-hook-form";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../../i18n";
|
import { useTranslation } from "../../../../../i18n";
|
||||||
import type { ProformaFormData } from "../../../../schema";
|
import type { ProformaFormData } from "../../../../types";
|
||||||
import { useProformaContext } from "../../context";
|
import { useProformaContext } from "../../context";
|
||||||
|
|
||||||
export const ProformaTotals = (props: ComponentProps<"fieldset">) => {
|
export const ProformaTotals = (props: ComponentProps<"fieldset">) => {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Button, Input, Label, Textarea } from "@repo/shadcn-ui/components";
|
import { Button, Input, Label, Textarea } from "@repo/shadcn-ui/components";
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import type { ProformaFormData, ProformaItemFormData } from "../../../../../schema";
|
import type { ProformaFormData, ProformaItemFormData } from "../../../../../types";
|
||||||
|
|
||||||
export function ItemRowEditor({
|
export function ItemRowEditor({
|
||||||
row,
|
row,
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
/** biome-ignore-all lint/complexity/noForEach: <explanation> */
|
/** biome-ignore-all lint/complexity/noForEach: <explanation> */
|
||||||
/** biome-ignore-all lint/suspicious/useIterableCallbackReturn: <explanation> */
|
/** biome-ignore-all lint/suspicious/useIterableCallbackReturn: <explanation> */
|
||||||
|
|
||||||
import { useProformaItemsColumns } from "@erp/customer-invoices/web/proformas/hooks";
|
import { useProformaGridColumns } from "@erp/customer-invoices/web/proformas/hooks";
|
||||||
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 { useProformaAutoRecalc } from "../../../../../../hooks";
|
import { useProformaAutoRecalc } from "../../../../../../hooks";
|
||||||
import { useTranslation } from "../../../../../../i18n";
|
import { useTranslation } from "../../../../../../i18n";
|
||||||
import { type ProformaFormData, defaultProformaItemFormData } from "../../../../../schema";
|
import { type ProformaFormData, defaultProformaItemFormData } from "../../../../../types";
|
||||||
import { useProformaContext } from "../../../context";
|
import { useProformaContext } from "../../../context";
|
||||||
|
|
||||||
import { ItemRowEditor } from "./item-row-editor";
|
import { ItemRowEditor } from "./item-row-editor";
|
||||||
@ -28,7 +28,7 @@ export const ItemsEditor = () => {
|
|||||||
name: "items",
|
name: "items",
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseColumns = useWithRowSelection(useProformaItemsColumns(), true);
|
const baseColumns = useWithRowSelection(useProformaGridColumns(), true);
|
||||||
const columns = useMemo(() => baseColumns, [baseColumns]);
|
const columns = useMemo(() => baseColumns, [baseColumns]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export * from "./proforma.api.schema";
|
export * from "./proforma.api.schema";
|
||||||
export * from "./proforma.form.schema";
|
export * from "./proforma.form.schema";
|
||||||
|
export * from "./proforma-status";
|
||||||
export * from "./proforma-summary.web.schema";
|
export * from "./proforma-summary.web.schema";
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
export enum PROFORMA_STATUS {
|
||||||
|
DRAFT = "draft",
|
||||||
|
SENT = "sent",
|
||||||
|
APPROVED = "approved",
|
||||||
|
REJECTED = "rejected",
|
||||||
|
ISSUED = "issued",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transiciones válidas según reglas del dominio
|
||||||
|
export const PROFORMA_STATUS_TRANSITIONS: Record<PROFORMA_STATUS, PROFORMA_STATUS[]> = {
|
||||||
|
[PROFORMA_STATUS.DRAFT]: [PROFORMA_STATUS.SENT],
|
||||||
|
[PROFORMA_STATUS.SENT]: [PROFORMA_STATUS.APPROVED, PROFORMA_STATUS.REJECTED],
|
||||||
|
[PROFORMA_STATUS.APPROVED]: [PROFORMA_STATUS.ISSUED, PROFORMA_STATUS.DRAFT],
|
||||||
|
[PROFORMA_STATUS.REJECTED]: [PROFORMA_STATUS.DRAFT],
|
||||||
|
[PROFORMA_STATUS.ISSUED]: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProformaStatus = `${PROFORMA_STATUS}`;
|
||||||
|
|
||||||
|
export const getProformaStatusButtonVariant = (
|
||||||
|
status: ProformaStatus
|
||||||
|
): "default" | "secondary" | "outline" | "destructive" => {
|
||||||
|
switch (status) {
|
||||||
|
case "draft":
|
||||||
|
return "outline";
|
||||||
|
case "sent":
|
||||||
|
return "secondary";
|
||||||
|
case "approved":
|
||||||
|
return "default";
|
||||||
|
case "rejected":
|
||||||
|
return "destructive";
|
||||||
|
case "issued":
|
||||||
|
return "default";
|
||||||
|
default:
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProformaStatusColor = (status: ProformaStatus): string => {
|
||||||
|
switch (status) {
|
||||||
|
case "draft":
|
||||||
|
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
|
||||||
|
case "sent":
|
||||||
|
return "bg-yellow-100 text-yellow-700 hover:bg-yellow-100";
|
||||||
|
case "approved":
|
||||||
|
return "bg-green-100 text-green-700 hover:bg-green-100";
|
||||||
|
case "rejected":
|
||||||
|
return "bg-red-100 text-red-700 hover:bg-red-100";
|
||||||
|
case "issued":
|
||||||
|
return "bg-blue-100 text-blue-700 hover:bg-blue-100";
|
||||||
|
default:
|
||||||
|
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,4 +1,5 @@
|
|||||||
|
export * from "../../issue-proforma/ui/issue-proforma-dialog";
|
||||||
|
|
||||||
export * from "./proforma-delete-dialog";
|
export * from "./proforma-delete-dialog";
|
||||||
export * from "./proforma-issue-dialog";
|
|
||||||
export * from "./proforma-layout";
|
export * from "./proforma-layout";
|
||||||
export * from "./proforma-tax-summary";
|
export * from "./proforma-tax-summary";
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
Button,
|
|
||||||
Spinner,
|
|
||||||
} from "@repo/shadcn-ui/components";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface IssueInvoiceDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
proformaId: number;
|
|
||||||
proformaReference: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProformaIssueDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
proformaId,
|
|
||||||
proformaReference,
|
|
||||||
}: IssueInvoiceDialogProps) {
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
//const { toast } = useToast();
|
|
||||||
|
|
||||||
const handleIssue = async () => {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
/*try {
|
|
||||||
const result = await issueInvoiceFromProforma(proformaId);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast({
|
|
||||||
title: "Factura emitida",
|
|
||||||
description: `Se ha emitido la factura #${result.invoiceId} desde la proforma.`,
|
|
||||||
});
|
|
||||||
onOpenChange(false);
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: error instanceof Error ? error.message : "Error al emitir la factura",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}*/
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Emitir factura</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
¿Estás seguro de que deseas emitir una factura de cliente desde la proforma{" "}
|
|
||||||
<strong>{proformaReference}</strong>?
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Esta acción creará una nueva factura definitiva y la proforma pasará al estado
|
|
||||||
"Emitida", no pudiendo modificarse ni eliminarse posteriormente.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button disabled={isSubmitting} onClick={handleIssue}>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<Spinner className="mr-2 size-4" />
|
|
||||||
Emitiendo...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Emitir factura"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -12,7 +12,7 @@ import { useFormContext, useWatch } from "react-hook-form";
|
|||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
import { useProformaContext } from "../../pages/update/context";
|
import { useProformaContext } from "../../pages/update/context";
|
||||||
import type { ProformaFormData } from "../../schema";
|
import type { ProformaFormData } from "../../types";
|
||||||
|
|
||||||
export const ProformaTaxSummary = (props: ComponentProps<"fieldset">) => {
|
export const ProformaTaxSummary = (props: ComponentProps<"fieldset">) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@ -3,9 +3,10 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<style>{{ asset 'tailwind.css' }}</style>
|
<link rel="stylesheet" href="{{ asset 'tailwind.css' }}" />
|
||||||
|
|
||||||
<title>Factura</title>
|
<title>Factura</title>
|
||||||
<style>
|
<style type="text/css">
|
||||||
/* ---------------------------- */
|
/* ---------------------------- */
|
||||||
/* ESTRUCTURA CABECERA */
|
/* ESTRUCTURA CABECERA */
|
||||||
/* ---------------------------- */
|
/* ---------------------------- */
|
||||||
@ -43,10 +44,12 @@
|
|||||||
|
|
||||||
/* Bloque derecho */
|
/* Bloque derecho */
|
||||||
.right-block {
|
.right-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-direction: column; /* uno encima de otro */
|
||||||
justify-content: flex-end;
|
align-items: flex-end; /* o flex-start / center según quieras */
|
||||||
width: 40%;
|
justify-content: flex-start;
|
||||||
|
width: 40%;
|
||||||
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.factura-img {
|
.factura-img {
|
||||||
@ -202,6 +205,7 @@
|
|||||||
<!-- FILA SUPERIOR: logo + dirección / imagen factura -->
|
<!-- FILA SUPERIOR: logo + dirección / imagen factura -->
|
||||||
<div class="top-header">
|
<div class="top-header">
|
||||||
<div class="left-block">
|
<div class="left-block">
|
||||||
|
|
||||||
<img src="{{asset 'logo_acana.jpg'}}" alt="Logo Acana" class="logo" />
|
<img src="{{asset 'logo_acana.jpg'}}" alt="Logo Acana" class="logo" />
|
||||||
|
|
||||||
<div class="company-text">
|
<div class="company-text">
|
||||||
@ -217,7 +221,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="right-block">
|
<div class="right-block">
|
||||||
|
<div>
|
||||||
<img src="{{asset 'factura_acana.jpg'}}" alt="Factura" class="factura-img" />
|
<img src="{{asset 'factura_acana.jpg'}}" alt="Factura" class="factura-img" />
|
||||||
|
</div>
|
||||||
|
{{#if verifactu.qr_code}}
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; padding-top: 10px;">
|
||||||
|
<div style="text-align: right;">
|
||||||
|
QR tributario factura verificable en sede electronica de AEAT VERI*FACTU
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<img src="{{verifactu.qr_code}}" alt="QR factura" style="width: 100px; height: 100px;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -227,7 +243,6 @@
|
|||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<p>Factura nº: <strong>{{series}}{{invoice_number}}</strong></p>
|
<p>Factura nº: <strong>{{series}}{{invoice_number}}</strong></p>
|
||||||
<p>Fecha: <strong>{{invoice_date}}</strong></p>
|
<p>Fecha: <strong>{{invoice_date}}</strong></p>
|
||||||
<p>Página <span class="pageNumber"></span> de <span class="totalPages"></span></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-box info-dire">
|
<div class="info-box info-dire">
|
||||||
@ -244,7 +259,6 @@
|
|||||||
<main id="main">
|
<main id="main">
|
||||||
<section id="details">
|
<section id="details">
|
||||||
|
|
||||||
|
|
||||||
<!-- Tu tabla -->
|
<!-- Tu tabla -->
|
||||||
<table class="table-header">
|
<table class="table-header">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<style>{{ asset 'tailwind.css' }}</style>
|
<link rel="stylesheet" href="{{ asset 'tailwind.css' }}" />
|
||||||
|
|
||||||
<title>Factura proforma</title>
|
<title>Factura proforma</title>
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
/* ---------------------------- */
|
/* ---------------------------- */
|
||||||
|
|||||||
@ -22,7 +22,8 @@
|
|||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplicate",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"move_up": "Move up",
|
"move_up": "Move up",
|
||||||
"move_down": "Move down"
|
"move_down": "Move down",
|
||||||
|
"clear_selection": "Clear selection"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"goto_first_page": "Go to first page",
|
"goto_first_page": "Go to first page",
|
||||||
|
|||||||
@ -25,7 +25,8 @@
|
|||||||
"duplicate": "Duplicar",
|
"duplicate": "Duplicar",
|
||||||
"remove": "Eliminar",
|
"remove": "Eliminar",
|
||||||
"move_up": "Subir",
|
"move_up": "Subir",
|
||||||
"move_down": "Bajar"
|
"move_down": "Bajar",
|
||||||
|
"clear_selection": "Quitar selección"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"goto_first_page": "Ir a la primera página",
|
"goto_first_page": "Ir a la primera página",
|
||||||
|
|||||||
@ -1,33 +1,29 @@
|
|||||||
import * as React from "react"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Separator } from "@repo/shadcn-ui/components/separator";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import { type VariantProps, cva } from "class-variance-authority";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils"
|
import type * as React from "react";
|
||||||
import { Separator } from "@repo/shadcn-ui/components/separator"
|
|
||||||
|
|
||||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="list"
|
|
||||||
data-slot="item-group"
|
|
||||||
className={cn("group/item-group flex flex-col", className)}
|
className={cn("group/item-group flex flex-col", className)}
|
||||||
|
data-slot="item-group"
|
||||||
|
role="list"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemSeparator({
|
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Separator>) {
|
|
||||||
return (
|
return (
|
||||||
<Separator
|
<Separator
|
||||||
|
className={cn("my-0", className)}
|
||||||
data-slot="item-separator"
|
data-slot="item-separator"
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
className={cn("my-0", className)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemVariants = cva(
|
const itemVariants = cva(
|
||||||
@ -49,7 +45,7 @@ const itemVariants = cva(
|
|||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
function Item({
|
function Item({
|
||||||
className,
|
className,
|
||||||
@ -57,18 +53,17 @@ function Item({
|
|||||||
size = "default",
|
size = "default",
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> &
|
}: React.ComponentProps<"div"> & VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||||
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
const Comp = asChild ? Slot : "div";
|
||||||
const Comp = asChild ? Slot : "div"
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
|
className={cn(itemVariants({ variant, size, className }))}
|
||||||
|
data-size={size}
|
||||||
data-slot="item"
|
data-slot="item"
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
data-size={size}
|
|
||||||
className={cn(itemVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemMediaVariants = cva(
|
const itemMediaVariants = cva(
|
||||||
@ -78,15 +73,14 @@ const itemMediaVariants = cva(
|
|||||||
variant: {
|
variant: {
|
||||||
default: "bg-transparent",
|
default: "bg-transparent",
|
||||||
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||||
image:
|
image: "size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
||||||
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
function ItemMedia({
|
function ItemMedia({
|
||||||
className,
|
className,
|
||||||
@ -95,88 +89,72 @@ function ItemMedia({
|
|||||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className={cn(itemMediaVariants({ variant, className }))}
|
||||||
data-slot="item-media"
|
data-slot="item-media"
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(itemMediaVariants({ variant, className }))}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className={cn("flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none", className)}
|
||||||
data-slot="item-content"
|
data-slot="item-content"
|
||||||
className={cn(
|
|
||||||
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className={cn("flex w-fit items-center gap-2 text-sm leading-snug font-medium", className)}
|
||||||
data-slot="item-title"
|
data-slot="item-title"
|
||||||
className={cn(
|
|
||||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
data-slot="item-description"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
||||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
data-slot="item-description"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn("flex items-center gap-2", className)} data-slot="item-actions" {...props} />
|
||||||
data-slot="item-actions"
|
);
|
||||||
className={cn("flex items-center gap-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className={cn("flex basis-full items-center justify-between gap-2", className)}
|
||||||
data-slot="item-header"
|
data-slot="item-header"
|
||||||
className={cn(
|
|
||||||
"flex basis-full items-center justify-between gap-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className={cn("flex basis-full items-center justify-between gap-2", className)}
|
||||||
data-slot="item-footer"
|
data-slot="item-footer"
|
||||||
className={cn(
|
|
||||||
"flex basis-full items-center justify-between gap-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -190,4 +168,4 @@ export {
|
|||||||
ItemDescription,
|
ItemDescription,
|
||||||
ItemHeader,
|
ItemHeader,
|
||||||
ItemFooter,
|
ItemFooter,
|
||||||
}
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user