feat: implement organization fiscal profile management including ARCA activity search and fiscal data configuration

This commit is contained in:
2026-08-10 13:05:09 -03:00
parent ebd8096112
commit 6e7bf6e835
23 changed files with 1876 additions and 187 deletions
@@ -29,8 +29,25 @@ export type ArcaWsfeLastVoucherResult = {
export type CreateInvoiceFromCashMovementParams = {
cashMovementId: string;
billingDate: string | Date;
paymentMethod: string;
description: string;
servicePeriodFrom: string | Date;
servicePeriodTo: string | Date;
paymentDueDate: string | Date;
activityCode: string;
activityDescription: string;
receiverIvaCondition?: 5;
receiverDocumentType?: string | number;
receiverDocumentNumber?: string | number;
receiverName?: string;
receiverAddress?: string;
receiverEmail?: string;
saleCondition: string;
itemDescription: string;
itemQuantity?: number;
itemUnit?: string;
itemUnitPrice: number;
itemDiscountPercent?: number;
itemDiscountAmount?: number;
itemSubtotal: number;
sessionUser: string;
};
@@ -51,8 +68,25 @@ export type Invoice = {
type: "FACTURA_C";
status: InvoiceStatus;
billingDate: Date;
paymentMethod: string;
description: string;
servicePeriodFrom: Date;
servicePeriodTo: Date;
paymentDueDate: Date;
activityCode: string;
activityDescription: string;
receiverIvaCondition: 5;
receiverDocumentType?: string | number;
receiverDocumentNumber?: string | number;
receiverName?: string;
receiverAddress?: string;
receiverEmail?: string;
saleCondition: string;
itemDescription: string;
itemQuantity: number;
itemUnit?: string;
itemUnitPrice: number;
itemDiscountPercent: number;
itemDiscountAmount: number;
itemSubtotal: number;
cae?: string;
caeExpiresAt?: Date;
pointOfSale?: number;
@@ -61,6 +95,20 @@ export type Invoice = {
arcaErrorMessage?: string;
};
export type OrganizationFiscalProfile = {
cuit?: string;
legalName?: string;
taxCondition?: "MONOTRIBUTO" | "EXENTO";
pointOfSale?: number;
activityCode?: string;
activityDescription?: string;
};
export type FindOrganizationFiscalProfilesParams = {
companyId: string;
sessionUser: string;
};
const schemaCreateCashFlowMovement = Yup.object().shape({
paymentMethod: Yup.string().required("Debe introducir el tipo de movimiento."),
companyId: Yup.string().required("La organización no es valida."),
@@ -136,3 +184,9 @@ export const createInvoiceFromCashMovement = async (
export const findInvoices = async (data: FindInvoicesParams): Promise<Invoice[]> => {
return ApiRequest.post<Invoice[]>("invoices/find", data);
};
export const findOrganizationFiscalProfiles = async (
data: FindOrganizationFiscalProfilesParams
): Promise<OrganizationFiscalProfile[]> => {
return ApiRequest.post<OrganizationFiscalProfile[]>("organization-fiscal-profiles/find", data);
};
@@ -138,6 +138,7 @@
.movementActionsSlot {
display: flex;
gap: 8px;
margin-top: 16px;
}
@@ -167,6 +168,17 @@
padding: 7px 10px;
}
.movementInvoicePdfButton {
background: var(--white);
border: 1px solid rgba(22, 128, 63, 0.24);
border-radius: 999px;
color: #166534;
cursor: pointer;
font-size: 12px;
font-weight: 800;
padding: 7px 10px;
}
.emptyState {
background: var(--white);
border: 1px dashed var(--gray-light);
@@ -188,7 +200,7 @@
display: flex;
inset: 0;
justify-content: center;
padding: 14px;
padding: 72px 14px 14px;
position: fixed;
z-index: 1000;
}
@@ -197,18 +209,36 @@
background: var(--white);
border-radius: 24px 24px 18px 18px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28);
max-height: calc(100vh - 28px);
overflow: auto;
display: flex;
flex-direction: column;
max-height: calc(100dvh - 86px);
min-height: 0;
overflow: hidden;
padding: 18px;
width: 100%;
}
.invoiceModalBody {
min-height: 0;
overflow-y: auto;
padding-right: 2px;
scrollbar-width: none;
}
.invoiceModalBody::-webkit-scrollbar {
display: none;
}
.invoiceModalHeader {
align-items: flex-start;
background: var(--white);
display: flex;
flex-shrink: 0;
gap: 12px;
justify-content: space-between;
margin-bottom: 16px;
position: relative;
z-index: 3;
}
.invoiceModalHeader h2 {
@@ -256,6 +286,44 @@
font-size: 22px;
}
.invoiceSteps {
background: var(--white);
display: flex;
gap: 8px;
margin-bottom: 14px;
overflow-x: auto;
padding-bottom: 8px;
position: sticky;
top: 0;
z-index: 2;
scrollbar-width: none;
}
.invoiceSteps::-webkit-scrollbar {
display: none;
}
.invoiceSteps span {
background: #f1f5f9;
border-radius: 999px;
color: var(--gray-dark);
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
padding: 7px 10px;
}
.invoiceSteps .invoiceStepActive {
background: rgba(22, 128, 63, 0.14);
color: #166534;
}
.invoiceGrid {
display: grid;
gap: 12px;
grid-template-columns: 1fr;
}
.invoiceField {
color: var(--black-light);
display: flex;
@@ -266,6 +334,10 @@
margin-bottom: 14px;
}
.invoiceFieldFull {
composes: invoiceField;
}
.invoiceField input,
.invoiceField select,
.invoiceField textarea {
@@ -284,11 +356,49 @@
resize: vertical;
}
.invoiceModalActions {
.invoiceToggle {
align-items: center;
color: var(--black-light);
display: flex;
flex-direction: column-reverse;
font-size: 13px;
font-weight: 700;
gap: 10px;
margin-bottom: 4px;
}
.invoiceToggle input {
height: 18px;
width: 18px;
}
.invoiceSummaryBox {
background: #f8fafc;
border: 1px solid var(--gray-light);
border-radius: 18px;
color: var(--black-light);
display: grid;
gap: 8px;
padding: 14px;
}
.invoiceSummaryBox p {
font-size: 13px;
line-height: 1.35;
margin: 0;
}
.invoiceModalActions {
background: var(--white);
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 10px;
margin-top: 18px;
padding-top: 12px;
}
.invoiceModalActions .invoiceConfirmButton {
order: -1;
}
.invoiceCancelButton,
@@ -352,13 +462,25 @@
.invoiceModal {
border-radius: 24px;
max-width: 520px;
max-width: 680px;
}
.invoiceGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.invoiceFieldFull {
grid-column: 1 / -1;
}
.invoiceModalActions {
flex-direction: row;
justify-content: flex-end;
}
.invoiceModalActions .invoiceConfirmButton {
order: initial;
}
}
@media (min-width: 1200px) {
@@ -19,7 +19,7 @@ import {
PaginateCashFlowParams,
PaginateCashFlowResults,
} from "@core/Models/CashFlow.model";
import { cashFlowPaginate, createInvoiceFromCashMovement, findInvoices, Invoice } from "../Cash.Service";
import { cashFlowPaginate, createInvoiceFromCashMovement, findInvoices, findOrganizationFiscalProfiles, Invoice, OrganizationFiscalProfile } from "../Cash.Service";
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
import Filterable from "@core/app/components/Filterable/Filterable";
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
@@ -189,32 +189,189 @@ const getMovementId = (movement: ICashFlow) => movement.id || movement._id || ""
const todayInputValue = () => dayjs().format("YYYY-MM-DD");
const invoicePaymentMethodOptions = [
{ id: CLIENT_ACCOUNT_PAYMENT_METHODS.CASH, name: "Efectivo" },
{ id: CLIENT_ACCOUNT_PAYMENT_METHODS.BANK_TRANSFER, name: "Transferencia bancaria" },
{ id: CLIENT_ACCOUNT_PAYMENT_METHODS.CREDIT_CARD, name: "Otros medios de pago electrónicos" },
{ id: CLIENT_ACCOUNT_PAYMENT_METHODS.DEBIT_CARD, name: "Tarjeta de débito" },
{ id: CLIENT_ACCOUNT_PAYMENT_METHODS.OTHER, name: "Otros" },
const saleConditionOptions = [
{ id: "cash", name: "Contado" },
{ id: "debit_card", name: "Tarjeta de débito" },
{ id: "credit_card", name: "Tarjeta de crédito" },
{ id: "current_account", name: "Cuenta corriente" },
{ id: "check", name: "Cheque" },
{ id: "bank_transfer", name: "Transferencia bancaria" },
{ id: "other", name: "Otra" },
{ id: "electronic_payment", name: "Otros medios de pago electrónicos" },
];
const isInvoicePaymentMethod = (paymentMethod?: string) => {
return invoicePaymentMethodOptions.some((option) => option.id === paymentMethod);
const receiverDocumentTypeOptions = [
{ id: "96", name: "DNI" },
{ id: "99", name: "Consumidor Final / sin documento" },
];
const isSaleCondition = (saleCondition?: string) => {
return saleConditionOptions.some((option) => option.id === saleCondition);
};
const getInvoicePaymentMethod = (paymentMethod?: string) => {
if (paymentMethod && isInvoicePaymentMethod(paymentMethod)) {
const getInvoiceSaleCondition = (paymentMethod?: string) => {
if (paymentMethod && isSaleCondition(paymentMethod)) {
return paymentMethod;
}
return CLIENT_ACCOUNT_PAYMENT_METHODS.CASH;
return "cash";
};
const getInvoiceServiceDescription = (movement: ICashFlow) => {
const description = (movement.description || "").trim();
if (!description) {
return "Servicio facturado";
}
const serviceMatch = description.match(/^[^-]+ - (.+?)(?: \([^)]+\))?$/);
return serviceMatch?.[1]?.trim() || description;
};
const formatInvoiceDate = (value?: string | Date) => value ? dayjs(value).format("DD/MM/YYYY") : "-";
const formatInvoiceNumber = (value?: number, length = 5) => String(value || 0).padStart(length, "0");
const formatInvoiceAmount = (value?: number) => new Intl.NumberFormat("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value || 0);
const getInvoiceSaleConditionLabel = (saleCondition?: string) => saleConditionOptions.find((option) => option.id === saleCondition)?.name || "Contado";
const getInvoiceTaxConditionLabel = (taxCondition?: string) => taxCondition === "EXENTO" ? "IVA Exento" : "Responsable Monotributo";
const buildInvoiceCopyPage = (copyLabel: string, invoice: Invoice, fiscalProfile: OrganizationFiscalProfile) => {
const legalName = fiscalProfile.legalName || "-";
const cuit = fiscalProfile.cuit || "-";
const pointOfSale = formatInvoiceNumber(invoice.pointOfSale || fiscalProfile.pointOfSale, 5);
const voucherNumber = formatInvoiceNumber(invoice.voucherNumber, 8);
const total = formatInvoiceAmount(invoice.itemSubtotal || invoice.amount);
const taxCondition = getInvoiceTaxConditionLabel(fiscalProfile.taxCondition);
const itemDescription = invoice.itemDescription || "Servicio facturado";
const itemUnit = invoice.itemUnit || "unidades";
return [
{
table: { widths: ["*"], body: [[{ text: copyLabel, style: "copyTitle" }]] },
margin: [0, 0, 0, 0],
},
{
table: {
widths: ["*", 54, "*"],
body: [[
{ stack: [
{ text: legalName, style: "issuerName" },
{ text: [{ text: "Razón Social: ", bold: true }, legalName], margin: [0, 24, 0, 0] },
{ text: [{ text: "Domicilio Comercial: ", bold: true }, "-"], margin: [0, 10, 0, 0] },
{ text: [{ text: "Condición frente al IVA: ", bold: true }, taxCondition], margin: [0, 10, 0, 0] },
], border: [true, true, false, true], margin: [6, 8, 6, 6] },
{ stack: [{ text: "C", style: "voucherLetter" }, { text: "COD. 011", style: "voucherCode" }], alignment: "center", border: [true, true, true, true], margin: [0, 6, 0, 4] },
{ stack: [
{ text: "FACTURA", style: "invoiceTitle" },
{ text: [{ text: "Punto de Venta: ", bold: true }, pointOfSale, { text: " Comp. Nro: ", bold: true }, voucherNumber], margin: [0, 8, 0, 0] },
{ text: [{ text: "Fecha de Emisión: ", bold: true }, formatInvoiceDate(invoice.billingDate)], margin: [0, 7, 0, 0] },
{ text: [{ text: "CUIT: ", bold: true }, cuit], margin: [0, 10, 0, 0] },
{ text: [{ text: "Ingresos Brutos: ", bold: true }, cuit], margin: [0, 2, 0, 0] },
{ text: [{ text: "Fecha de Inicio de Actividades: ", bold: true }, "-"], margin: [0, 2, 0, 0] },
], border: [false, true, true, true], margin: [18, 8, 6, 6] },
]],
},
layout: "noPadding",
},
{
table: { widths: ["33%", "28%", "39%"], body: [[
{ text: [{ text: "Período Facturado Desde: ", bold: true }, formatInvoiceDate(invoice.servicePeriodFrom)] },
{ text: [{ text: "Hasta: ", bold: true }, formatInvoiceDate(invoice.servicePeriodTo)] },
{ text: [{ text: "Fecha de Vto. para el pago: ", bold: true }, formatInvoiceDate(invoice.paymentDueDate)] },
]] },
style: "smallTable",
},
{
table: { widths: ["34%", "33%", "33%"], body: [
[{ text: "Doc.: -", bold: true }, { text: "Apellido y Nombre / Razón Social:", bold: true }, { text: "" }],
[{ text: [{ text: "Condición frente al IVA: ", bold: true }, "Consumidor Final"] }, { text: "Domicilio:", bold: true }, { text: invoice.receiverAddress || "" }],
[{ text: [{ text: "Condición de venta: ", bold: true }, getInvoiceSaleConditionLabel(invoice.saleCondition)], colSpan: 3 }, {}, {}],
] },
margin: [0, 0, 0, 2],
},
{
table: {
headerRows: 1,
widths: [42, "*", 58, 52, 78, 45, 70, 78],
body: [
["Código", "Producto / Servicio", "Cantidad", "U. Medida", "Precio Unit.", "% Bonif", "Imp. Bonif.", "Subtotal"].map((text) => ({ text, bold: true, fillColor: "#d7d7d7", alignment: "center" })),
["", itemDescription, formatInvoiceAmount(invoice.itemQuantity || 1), itemUnit, formatInvoiceAmount(invoice.itemUnitPrice || invoice.amount), formatInvoiceAmount(invoice.itemDiscountPercent), formatInvoiceAmount(invoice.itemDiscountAmount), total],
],
},
fontSize: 7,
},
{ text: " ", margin: [0, 154, 0, 0] },
{
table: { widths: ["*"], body: [[{ stack: [
{ text: [{ text: "Subtotal: $ ", bold: true }, total], alignment: "right", margin: [0, 8, 0, 0] },
{ text: [{ text: "Importe Otros Tributos: $ ", bold: true }, "0,00"], alignment: "right", margin: [0, 8, 0, 0] },
{ text: [{ text: "Importe Total: $ ", bold: true }, total], alignment: "right", margin: [0, 8, 0, 8] },
] }]] },
},
{
columns: [
{ columns: [
{ qr: buildArcaQrUrl(invoice, fiscalProfile), fit: 66, width: 78, margin: [0, 13, 0, 0] },
{ stack: [{ text: "ARCA", style: "arcaLogo" }, { text: "AGENCIA DE RECAUDACIÓN\nY CONTROL ADUANERO", fontSize: 5, color: "#6b7280", margin: [0, -3, 0, 0] }, { text: "Comprobante Autorizado", bold: true, italics: true, margin: [0, 11, 0, 0] }, { text: "Esta Agencia no se responsabiliza por los datos ingresados en el detalle de la operación", fontSize: 6, italics: true, margin: [0, 8, 0, 0] }], width: "*" },
], width: "40%" },
{ text: "Pág. 1/1", bold: true, alignment: "center", width: "20%", margin: [0, 34, 0, 0] },
{ stack: [{ text: [{ text: "CAE N°: ", bold: true }, invoice.cae || "-"] }, { text: [{ text: "Fecha de Vto. de CAE: ", bold: true }, formatInvoiceDate(invoice.caeExpiresAt)], margin: [0, 8, 0, 0] }], width: "40%", alignment: "right", margin: [0, 30, 0, 0] },
],
margin: [0, 30, 0, 0],
pageBreak: copyLabel === "TRIPLICADO" ? undefined : "after",
},
];
};
const buildArcaQrUrl = (invoice: Invoice, fiscalProfile: OrganizationFiscalProfile) => {
const payload = {
ver: 1,
fecha: dayjs(invoice.billingDate).format("YYYY-MM-DD"),
cuit: Number(fiscalProfile.cuit || 0),
ptoVta: invoice.pointOfSale || fiscalProfile.pointOfSale || 0,
tipoCmp: 11,
nroCmp: invoice.voucherNumber || 0,
importe: Number(invoice.itemSubtotal || invoice.amount || 0),
moneda: "PES",
ctz: 1,
tipoDocRec: Number(invoice.receiverDocumentType || 99),
nroDocRec: Number(invoice.receiverDocumentNumber || 0),
tipoCodAut: "E",
codAut: Number(invoice.cae || 0),
};
return `https://www.afip.gob.ar/fe/qr/?p=${btoa(JSON.stringify(payload))}`;
};
const invoiceWizardSteps = ["Datos de emisión", "Datos del receptor", "Datos de la operación", "Resumen"];
type InvoiceDraft = {
movement: ICashFlow;
movementId: string;
billingDate: string;
paymentMethod: string;
description: string;
servicePeriodFrom: string;
servicePeriodTo: string;
paymentDueDate: string;
activityCode: string;
activityDescription: string;
saleCondition: string;
includeReceiverData: boolean;
receiverDocumentType: string;
receiverDocumentNumber: string;
receiverName: string;
receiverAddress: string;
receiverEmail: string;
itemDescription: string;
itemQuantity: number;
itemUnit: string;
itemUnitPrice: number;
itemDiscountPercent: number;
itemDiscountAmount: number;
itemSubtotal: number;
};
const getPaymentMethodCardVisual = (paymentMethod?: PAYMENT_METHODS | string) => {
@@ -304,6 +461,8 @@ export default function CashFlowMovementsList() {
const [invoicingMovementId, setInvoicingMovementId] = useState<string>("");
const [invoiceDraft, setInvoiceDraft] = useState<InvoiceDraft | null>(null);
const [invoiceDraftsByMovementId, setInvoiceDraftsByMovementId] = useState<Record<string, InvoiceDraft>>({});
const [fiscalProfile, setFiscalProfile] = useState<OrganizationFiscalProfile>({ activityCode: "", activityDescription: "" });
const [invoiceStep, setInvoiceStep] = useState(0);
const movementsRef = useRef(movements);
const loadMovements = (page: number = 1, pageSize: number = 30) => {
@@ -428,6 +587,19 @@ export default function CashFlowMovementsList() {
loadMovements(loadPage, pageSize);
}, [clientId, collaboratorId, methodId]);
useEffect(() => {
if (!SessionInfo.userId) return;
findOrganizationFiscalProfiles({ companyId: id, sessionUser: SessionInfo.userId })
.then((profiles) => {
const profile = profiles[0];
setFiscalProfile(profile || { activityCode: "", activityDescription: "" });
})
.catch(() => {
setFiscalProfile({ activityCode: "", activityDescription: "" });
});
}, [SessionInfo.userId]);
const renderMovementMeta = (movement: ICashFlow) => {
const details = [
movement.paymentMethod ? getPaymentMethodName(movement.paymentMethod) : null,
@@ -463,12 +635,32 @@ export default function CashFlowMovementsList() {
const savedDraft = invoiceDraftsByMovementId[movementId];
const defaultDate = todayInputValue();
setInvoiceStep(0);
setInvoiceDraft(savedDraft ? { ...savedDraft, movement } : {
movement,
movementId,
billingDate: dayjs(movement.createdAt).format("YYYY-MM-DD"),
paymentMethod: getInvoicePaymentMethod(movement.paymentMethod),
description: movement.description || "",
billingDate: defaultDate,
servicePeriodFrom: defaultDate,
servicePeriodTo: defaultDate,
paymentDueDate: defaultDate,
activityCode: fiscalProfile.activityCode || "",
activityDescription: fiscalProfile.activityDescription || "",
saleCondition: getInvoiceSaleCondition(movement.paymentMethod),
includeReceiverData: false,
receiverDocumentType: "",
receiverDocumentNumber: "",
receiverName: "",
receiverAddress: "",
receiverEmail: "",
itemDescription: getInvoiceServiceDescription(movement),
itemQuantity: 1,
itemUnit: "",
itemUnitPrice: movement.amount,
itemDiscountPercent: 0,
itemDiscountAmount: 0,
itemSubtotal: movement.amount,
});
};
@@ -483,6 +675,65 @@ export default function CashFlowMovementsList() {
return remaining;
});
setInvoiceDraft(null);
setInvoiceStep(0);
}
};
const validateInvoiceStep = (step: number) => {
if (!invoiceDraft) return false;
if (step === 0) {
if (!invoiceDraft.billingDate || !invoiceDraft.servicePeriodFrom || !invoiceDraft.servicePeriodTo || !invoiceDraft.paymentDueDate) {
alert.showError("Debe completar las fechas de emisión, período y vencimiento.");
return false;
}
if (invoiceDraft.servicePeriodFrom > invoiceDraft.servicePeriodTo) {
alert.showError("El inicio del período facturado no puede ser posterior al fin.");
return false;
}
if (!invoiceDraft.activityCode.trim() || !invoiceDraft.activityDescription.trim()) {
alert.showError("Debe completar el código y la descripción de actividad.");
return false;
}
}
if (step === 1 && !invoiceDraft.saleCondition) {
alert.showError("Debe seleccionar la condición de venta.");
return false;
}
if (step === 1 && invoiceDraft.includeReceiverData) {
if (!invoiceDraft.receiverDocumentType) {
alert.showError("Debe seleccionar el tipo de documento del receptor.");
return false;
}
if (invoiceDraft.receiverDocumentType === "99" && invoiceDraft.receiverDocumentNumber.trim() !== "0") {
alert.showError("El número de documento debe ser 0 para Consumidor Final sin documento.");
return false;
}
}
if (step === 2) {
if (!invoiceDraft.itemDescription.trim()) {
alert.showError("Debe ingresar la descripción del servicio facturado.");
return false;
}
if (Math.abs(invoiceDraft.itemSubtotal - invoiceDraft.movement.amount) > 0.01) {
alert.showError("El total del ítem debe coincidir con el importe del movimiento.");
return false;
}
}
return true;
};
const goToNextInvoiceStep = () => {
if (validateInvoiceStep(invoiceStep)) {
setInvoiceStep((current) => Math.min(current + 1, invoiceWizardSteps.length - 1));
}
};
@@ -496,13 +747,7 @@ export default function CashFlowMovementsList() {
return;
}
if (!invoiceDraft.paymentMethod) {
alert.showError("Debe seleccionar el medio de pago.");
return;
}
if (!invoiceDraft.description.trim()) {
alert.showError("Debe ingresar el detalle de la factura.");
if (!invoiceWizardSteps.every((_, index) => validateInvoiceStep(index))) {
return;
}
@@ -512,8 +757,25 @@ export default function CashFlowMovementsList() {
createInvoiceFromCashMovement({
cashMovementId: invoiceDraft.movementId,
billingDate: invoiceDraft.billingDate,
paymentMethod: invoiceDraft.paymentMethod,
description: invoiceDraft.description.trim(),
servicePeriodFrom: invoiceDraft.servicePeriodFrom,
servicePeriodTo: invoiceDraft.servicePeriodTo,
paymentDueDate: invoiceDraft.paymentDueDate,
activityCode: invoiceDraft.activityCode.trim(),
activityDescription: invoiceDraft.activityDescription.trim(),
receiverIvaCondition: 5,
receiverDocumentType: invoiceDraft.includeReceiverData ? invoiceDraft.receiverDocumentType : undefined,
receiverDocumentNumber: invoiceDraft.includeReceiverData ? invoiceDraft.receiverDocumentNumber.trim() : undefined,
receiverName: invoiceDraft.includeReceiverData ? invoiceDraft.receiverName.trim() : undefined,
receiverAddress: invoiceDraft.includeReceiverData ? invoiceDraft.receiverAddress.trim() : undefined,
receiverEmail: invoiceDraft.includeReceiverData ? invoiceDraft.receiverEmail.trim() : undefined,
saleCondition: invoiceDraft.saleCondition,
itemDescription: invoiceDraft.itemDescription.trim(),
itemQuantity: invoiceDraft.itemQuantity,
itemUnit: invoiceDraft.itemUnit.trim(),
itemUnitPrice: invoiceDraft.itemUnitPrice,
itemDiscountPercent: invoiceDraft.itemDiscountPercent,
itemDiscountAmount: invoiceDraft.itemDiscountAmount,
itemSubtotal: invoiceDraft.itemSubtotal,
sessionUser: SessionInfo.userId,
})
.then((invoice) => {
@@ -550,10 +812,45 @@ export default function CashFlowMovementsList() {
.finally(() => {
setInvoicingMovementId("");
setInvoiceDraft(null);
setInvoiceStep(0);
eventHandler.setEventType(EVENT_TYPES.CLEAR);
});
};
const handleDownloadInvoicePdf = async (invoice: Invoice) => {
if (invoice.status !== "APPROVED") {
alert.showError("Solo se puede descargar el PDF de facturas aprobadas.");
return;
}
try {
const pdfMake = await import("pdfmake/build/pdfmake");
const pdfFonts = (await import("pdfmake/build/vfs_fonts")) as any;
pdfMake.vfs = pdfFonts.pdfMake ? pdfFonts.pdfMake.vfs : pdfFonts.vfs;
const content = ["ORIGINAL", "DUPLICADO", "TRIPLICADO"].flatMap((copyLabel) => buildInvoiceCopyPage(copyLabel, invoice, fiscalProfile));
const documentDefinition: any = {
pageSize: "A4",
pageMargins: [24, 14, 24, 18],
defaultStyle: { fontSize: 8, color: "#111111" },
styles: {
copyTitle: { fontSize: 15, bold: true, alignment: "center", margin: [0, 4, 0, 4] },
issuerName: { bold: true, alignment: "center", margin: [0, 14, 0, 0] },
voucherLetter: { fontSize: 27, bold: true },
voucherCode: { fontSize: 8, bold: true },
invoiceTitle: { fontSize: 22, bold: true, margin: [0, 6, 0, 0] },
smallTable: { margin: [0, 0, 0, 0] },
arcaLogo: { fontSize: 20, bold: true, color: "#4b5563", margin: [0, 13, 0, 0] },
},
};
(pdfMake as any).createPdf({ ...documentDefinition, content }).download(`factura-c-${invoice.pointOfSale || fiscalProfile.pointOfSale || 0}-${invoice.voucherNumber || 0}.pdf`);
} catch (error) {
console.error("Error generating invoice PDF:", error);
alert.showError("No pudimos generar el PDF de la factura.");
}
};
return (
<>
<Location />
@@ -612,9 +909,16 @@ export default function CashFlowMovementsList() {
<div className={style.movementActionsSlot}>
{isInvoiced ? (
<span className={style.movementInvoiceBadge}>
Factura C {invoice?.pointOfSale}-{invoice?.voucherNumber}
</span>
<>
<span className={style.movementInvoiceBadge}>
Factura C {invoice?.pointOfSale}-{invoice?.voucherNumber}
</span>
{invoice && (
<button type="button" className={style.movementInvoicePdfButton} onClick={() => handleDownloadInvoicePdf(invoice)}>
PDF
</button>
)}
</>
) : isPendingInvoice ? (
<span className={style.movementInvoiceBadge} title={getInvoiceStatusMessage(invoice)}>
Factura pendiente
@@ -671,58 +975,113 @@ export default function CashFlowMovementsList() {
</button>
</div>
<div className={style.invoiceAmountBox}>
<span>Importe</span>
<strong>{formatPrice(invoiceDraft.movement.amount)}</strong>
</div>
<label className={style.invoiceField}>
<span>Fecha de comprobante</span>
<input
type="date"
value={invoiceDraft.billingDate}
max={todayInputValue()}
onChange={(event) =>
setInvoiceDraft((current) =>
current ? { ...current, billingDate: event.target.value } : current
)
}
disabled={Boolean(invoicingMovementId)}
/>
</label>
<label className={style.invoiceField}>
<span>Medio de pago</span>
<select
value={invoiceDraft.paymentMethod}
onChange={(event) =>
setInvoiceDraft((current) =>
current ? { ...current, paymentMethod: event.target.value } : current
)
}
disabled={Boolean(invoicingMovementId)}
>
{invoicePaymentMethodOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.name}
</option>
<div className={style.invoiceModalBody}>
<div className={style.invoiceSteps}>
{invoiceWizardSteps.map((step, index) => (
<span key={step} className={index === invoiceStep ? style.invoiceStepActive : ""}>
{index + 1}. {step}
</span>
))}
</select>
</label>
</div>
<label className={style.invoiceField}>
<span>Detalle</span>
<textarea
value={invoiceDraft.description}
rows={4}
onChange={(event) =>
setInvoiceDraft((current) =>
current ? { ...current, description: event.target.value } : current
)
}
disabled={Boolean(invoicingMovementId)}
/>
</label>
<div className={style.invoiceAmountBox}>
<span>Total del comprobante</span>
<strong>{formatPrice(invoiceDraft.itemSubtotal)}</strong>
</div>
{invoiceStep === 0 && (
<div className={style.invoiceGrid}>
<label className={style.invoiceField}>
<span>Fecha de comprobante</span>
<input type="date" value={invoiceDraft.billingDate} max={todayInputValue()} onChange={(event) => setInvoiceDraft((current) => current ? { ...current, billingDate: event.target.value } : current)} disabled={Boolean(invoicingMovementId)} />
</label>
<label className={style.invoiceField}>
<span>Concepto</span>
<input type="text" value="Servicios" disabled />
</label>
<label className={style.invoiceField}>
<span>Período facturado desde</span>
<input type="date" value={invoiceDraft.servicePeriodFrom} onChange={(event) => setInvoiceDraft((current) => current ? { ...current, servicePeriodFrom: event.target.value } : current)} disabled={Boolean(invoicingMovementId)} />
</label>
<label className={style.invoiceField}>
<span>Período facturado hasta</span>
<input type="date" value={invoiceDraft.servicePeriodTo} onChange={(event) => setInvoiceDraft((current) => current ? { ...current, servicePeriodTo: event.target.value } : current)} disabled={Boolean(invoicingMovementId)} />
</label>
<label className={style.invoiceField}>
<span>Vencimiento de pago</span>
<input type="date" value={invoiceDraft.paymentDueDate} onChange={(event) => setInvoiceDraft((current) => current ? { ...current, paymentDueDate: event.target.value } : current)} disabled={Boolean(invoicingMovementId)} />
</label>
<label className={style.invoiceField}>
<span>Código de actividad</span>
<input type="text" value={invoiceDraft.activityCode} onChange={(event) => setInvoiceDraft((current) => current ? { ...current, activityCode: event.target.value } : current)} disabled={Boolean(invoicingMovementId)} />
</label>
<label className={style.invoiceFieldFull}>
<span>Descripción de actividad</span>
<input type="text" value={invoiceDraft.activityDescription} onChange={(event) => setInvoiceDraft((current) => current ? { ...current, activityDescription: event.target.value } : current)} disabled={Boolean(invoicingMovementId)} placeholder="Integración futura con perfil fiscal" />
</label>
</div>
)}
{invoiceStep === 1 && (
<div className={style.invoiceGrid}>
<label className={style.invoiceField}>
<span>Condición frente al IVA</span>
<input type="text" value="Consumidor Final" disabled />
</label>
<label className={style.invoiceField}>
<span>Condición de venta</span>
<select value={invoiceDraft.saleCondition} onChange={(event) => setInvoiceDraft((current) => current ? { ...current, saleCondition: event.target.value } : current)} disabled={Boolean(invoicingMovementId)}>
{saleConditionOptions.map((option) => <option key={option.id} value={option.id}>{option.name}</option>)}
</select>
</label>
<label className={style.invoiceToggle}>
<input type="checkbox" checked={invoiceDraft.includeReceiverData} onChange={(event) => setInvoiceDraft((current) => current ? { ...current, includeReceiverData: event.target.checked } : current)} disabled={Boolean(invoicingMovementId)} />
<span>Agregar datos del consumidor final</span>
</label>
{invoiceDraft.includeReceiverData && (
<>
<label className={style.invoiceField}>
<span>Tipo documento</span>
<select value={invoiceDraft.receiverDocumentType} onChange={(event) => setInvoiceDraft((current) => current ? { ...current, receiverDocumentType: event.target.value, receiverDocumentNumber: event.target.value === "99" ? "0" : current.receiverDocumentNumber } : current)} disabled={Boolean(invoicingMovementId)}>
<option value="">Seleccionar</option>
{receiverDocumentTypeOptions.map((option) => <option key={option.id} value={option.id}>{option.name}</option>)}
</select>
</label>
<label className={style.invoiceField}><span>Nro. documento</span><input type="text" value={invoiceDraft.receiverDocumentNumber} onChange={(event) => setInvoiceDraft((current) => current ? { ...current, receiverDocumentNumber: event.target.value } : current)} disabled={Boolean(invoicingMovementId) || invoiceDraft.receiverDocumentType === "99"} /></label>
<label className={style.invoiceField}><span>Nombre / razón social</span><input type="text" value={invoiceDraft.receiverName} onChange={(event) => setInvoiceDraft((current) => current ? { ...current, receiverName: event.target.value } : current)} disabled={Boolean(invoicingMovementId)} /></label>
<label className={style.invoiceField}><span>Domicilio comercial</span><input type="text" value={invoiceDraft.receiverAddress} onChange={(event) => setInvoiceDraft((current) => current ? { ...current, receiverAddress: event.target.value } : current)} disabled={Boolean(invoicingMovementId)} /></label>
<label className={style.invoiceFieldFull}><span>Email</span><input type="email" value={invoiceDraft.receiverEmail} onChange={(event) => setInvoiceDraft((current) => current ? { ...current, receiverEmail: event.target.value } : current)} disabled={Boolean(invoicingMovementId)} /></label>
</>
)}
</div>
)}
{invoiceStep === 2 && (
<div className={style.invoiceGrid}>
<label className={style.invoiceFieldFull}>
<span>Descripción del servicio</span>
<textarea value={invoiceDraft.itemDescription} rows={4} onChange={(event) => setInvoiceDraft((current) => current ? { ...current, itemDescription: event.target.value } : current)} disabled={Boolean(invoicingMovementId)} />
</label>
<label className={style.invoiceField}><span>Cantidad</span><input type="number" value={invoiceDraft.itemQuantity} disabled /></label>
<label className={style.invoiceField}><span>Unidad</span><input type="text" value={invoiceDraft.itemUnit} onChange={(event) => setInvoiceDraft((current) => current ? { ...current, itemUnit: event.target.value } : current)} disabled={Boolean(invoicingMovementId)} /></label>
<label className={style.invoiceField}><span>Precio unitario</span><input type="number" value={invoiceDraft.itemUnitPrice} disabled /></label>
<label className={style.invoiceField}><span>Subtotal</span><input type="number" value={invoiceDraft.itemSubtotal} disabled /></label>
</div>
)}
{invoiceStep === 3 && (
<div className={style.invoiceSummaryBox}>
<p><strong>Comprobante:</strong> Factura C de servicios, fecha {invoiceDraft.billingDate}</p>
<p><strong>Período:</strong> {invoiceDraft.servicePeriodFrom} al {invoiceDraft.servicePeriodTo}</p>
<p><strong>Vencimiento:</strong> {invoiceDraft.paymentDueDate}</p>
<p><strong>Actividad:</strong> {invoiceDraft.activityCode} - {invoiceDraft.activityDescription}</p>
<p><strong>Receptor:</strong> Consumidor Final{invoiceDraft.includeReceiverData && invoiceDraft.receiverName ? ` - ${invoiceDraft.receiverName}` : ""}</p>
<p><strong>Condición de venta:</strong> {saleConditionOptions.find((option) => option.id === invoiceDraft.saleCondition)?.name}</p>
<p><strong>Ítem:</strong> {invoiceDraft.itemDescription}</p>
<p><strong>Total:</strong> {formatPrice(invoiceDraft.itemSubtotal)}</p>
</div>
)}
</div>
<div className={style.invoiceModalActions}>
<button
@@ -733,14 +1092,14 @@ export default function CashFlowMovementsList() {
>
Cancelar
</button>
<button
type="button"
className={style.invoiceConfirmButton}
onClick={handleInvoiceMovement}
disabled={Boolean(invoicingMovementId)}
>
{invoicingMovementId ? "Facturando..." : "Emitir factura"}
</button>
{invoiceStep > 0 && <button type="button" className={style.invoiceCancelButton} onClick={() => setInvoiceStep((current) => current - 1)} disabled={Boolean(invoicingMovementId)}>Atrás</button>}
{invoiceStep < invoiceWizardSteps.length - 1 ? (
<button type="button" className={style.invoiceConfirmButton} onClick={goToNextInvoiceStep} disabled={Boolean(invoicingMovementId)}>Siguiente</button>
) : (
<button type="button" className={style.invoiceConfirmButton} onClick={handleInvoiceMovement} disabled={Boolean(invoicingMovementId)}>
{invoicingMovementId ? "Facturando..." : "Emitir factura"}
</button>
)}
</div>
</section>
</div>
@@ -0,0 +1,47 @@
import ApiRequest from "@services/Api.Service";
import { TextObjectFilterResult } from "@models/TextObjectFilter.model";
export type OrganizationFiscalProfileTaxCondition = "MONOTRIBUTO" | "EXENTO";
export type OrganizationFiscalProfile = {
id?: string;
companyId: string;
cuit: string;
legalName: string;
taxCondition: OrganizationFiscalProfileTaxCondition;
pointOfSale: number;
activityCode?: string;
activityDescription?: string;
};
export type FindOrganizationFiscalProfilesParams = {
companyId: string;
sessionUser: string;
};
export type UpsertOrganizationFiscalProfileParams = {
companyId: string;
cuit: string;
legalName: string;
taxCondition: OrganizationFiscalProfileTaxCondition;
pointOfSale: number;
activityCode?: string;
activityDescription?: string;
sessionUser: string;
};
export const findOrganizationFiscalProfiles = async (
data: FindOrganizationFiscalProfilesParams
): Promise<OrganizationFiscalProfile[]> => {
return ApiRequest.post<OrganizationFiscalProfile[]>("organization-fiscal-profiles/find", data);
};
export const upsertOrganizationFiscalProfile = async (
data: UpsertOrganizationFiscalProfileParams
): Promise<OrganizationFiscalProfile> => {
return ApiRequest.post<OrganizationFiscalProfile>("organization-fiscal-profiles/upsert", data);
};
export const findArcaActivities = async (): Promise<TextObjectFilterResult[]> => {
return ApiRequest.post<TextObjectFilterResult[]>("arca-activities/filter-all", {});
};
@@ -0,0 +1,7 @@
"use client";
import Saveable from "@components/Saveable/Saveable";
export default function OrgFiscalLayout({ children }: { children: React.ReactNode }) {
return <Saveable>{children}</Saveable>;
}
@@ -0,0 +1,136 @@
.pageContainer {
display: flex;
flex-direction: column;
gap: 18px;
margin: 12px auto 0;
max-width: 860px;
padding: 0 14px 40px;
width: 100%;
}
.heroCard {
align-items: flex-start;
background: linear-gradient(135deg, #4b6cb7 0%, #182848 100%);
border-radius: 22px;
box-shadow: 0 14px 34px rgba(24, 40, 72, 0.18);
color: var(--white);
display: flex;
gap: 14px;
padding: 20px;
}
.heroCard h2 {
font-size: 22px;
line-height: 1.2;
margin: 0 0 8px;
}
.heroCard p {
font-size: 14px;
line-height: 1.45;
margin: 0;
opacity: 0.86;
}
.heroIcon {
align-items: center;
background: rgba(255, 255, 255, 0.14);
border-radius: 16px;
display: flex;
flex: 0 0 48px;
height: 48px;
justify-content: center;
}
.heroIcon svg {
font-size: 30px;
}
.formSection {
background: var(--white);
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 20px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
padding: 18px;
}
.formSection h3 {
color: var(--black-light);
font-size: 16px;
margin: 0 0 16px;
}
.sectionHeader {
margin-bottom: 14px;
}
.sectionHeader h3 {
margin-bottom: 4px;
}
.sectionHeader p {
color: var(--gray-dark);
font-size: 13px;
margin: 0;
}
.optionGroup {
display: grid;
gap: 10px;
grid-template-columns: 1fr;
}
.optionCard {
background: #f8fafc;
border: 1px solid rgba(100, 116, 139, 0.22);
border-radius: 18px;
color: var(--black-light);
cursor: pointer;
padding: 14px 16px;
text-align: left;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease, background 0.2s ease;
}
.optionCard:hover {
border-color: rgba(124, 58, 237, 0.38);
box-shadow: 0 8px 20px rgba(124, 58, 237, 0.12);
transform: translateY(-1px);
}
.optionCardSelected {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.14) 0%, rgba(75, 108, 183, 0.1) 100%);
border-color: #7c3aed;
box-shadow: 0 10px 24px rgba(124, 58, 237, 0.16);
}
.optionTitle {
display: block;
font-size: 14px;
font-weight: 800;
margin-bottom: 4px;
}
.optionDescription {
color: var(--gray-dark);
display: block;
font-size: 12px;
line-height: 1.35;
}
.helpText {
color: var(--gray-dark);
font-size: 13px;
line-height: 1.5;
margin: 10px 0 0;
}
@media (min-width: 720px) {
.pageContainer {
padding-left: 24px;
padding-right: 24px;
}
.optionGroup {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@@ -0,0 +1,310 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Location from "@components/Location/Location";
import Textbox from "@components/Textbox/Textbox";
import TextObjectFilter from "@components/TextObjectFilter/TextObjectFilter";
import TextObjectFilterItem from "@components/TextObjectFilterItem/TextObjectFilterItem";
import { TextObjectFilterResult } from "@models/TextObjectFilter.model";
import { TextObjectModalContent } from "@store/TextObjectModal.Store";
import { useLocationStore } from "@store/Location.Store";
import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store";
import { useSessionStore } from "@store/Sesion.Store";
import { useAlert } from "@store/Alert.Store";
import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined";
import ReceiptLongOutlinedIcon from "@mui/icons-material/ReceiptLongOutlined";
import { Grid2 } from "@mui/material";
import { ApiError } from "@models/Server.Error.model";
import style from "./page.module.css";
import {
findArcaActivities,
findOrganizationFiscalProfiles,
OrganizationFiscalProfileTaxCondition,
upsertOrganizationFiscalProfile,
} from "./Fiscal.Service";
type UrlData = {
id: string;
};
export default function OrganizationFiscalProfileEdit() {
const data = useParams<UrlData>();
const id = data.id;
const location = useLocationStore();
const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore();
const alert = useAlert();
const [cuit, setCuit] = useState("");
const [legalName, setLegalName] = useState("");
const [taxCondition, setTaxCondition] = useState<OrganizationFiscalProfileTaxCondition>("MONOTRIBUTO");
const [pointOfSale, setPointOfSale] = useState("1");
const [activityCode, setActivityCode] = useState("");
const [activityDescription, setActivityDescription] = useState("");
const [arcaActivities, setArcaActivities] = useState<TextObjectFilterResult[]>([]);
const [fiscalDataLoaded, setFiscalDataLoaded] = useState(false);
const [activityFilterContent] = useState<TextObjectModalContent>({
header: (
<div>
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
Seleccionar actividad ARCA/CLAE
</div>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
Buscá por código o descripción de la actividad declarada.
</p>
</div>
),
bottom: <div>&nbsp;</div>,
renderResult: (result: TextObjectFilterResult, index: number) => (
<TextObjectFilterItem key={index} data={result} />
),
});
const markNeedsSave = () => eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
const fiscalConditionOptions: Array<{
id: OrganizationFiscalProfileTaxCondition;
title: string;
description: string;
}> = [
{
id: "MONOTRIBUTO",
title: "Monotributo",
description: "Para organizaciones adheridas al régimen simplificado.",
},
];
useEffect(() => {
location.clear();
location.add({
url: "/admin/org",
title: "Organizaciones",
icon: null,
});
location.add({
url: "/admin/org/profile/" + id,
title: "Perfil",
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />,
});
location.add({
url: "/admin/org/profile/" + id + "/fiscal",
title: "Datos fiscales",
icon: <ReceiptLongOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
}, []);
useEffect(() => {
if (!SessionInfo.userId) return;
eventHandler.setEventType(EVENT_TYPES.LOADING);
Promise.all([
findOrganizationFiscalProfiles({ companyId: id, sessionUser: SessionInfo.userId }),
findArcaActivities(),
])
.then(([profiles, activities]) => {
setArcaActivities(activities);
const profile = profiles[0];
if (!profile) return;
setCuit(profile.cuit || "");
setLegalName(profile.legalName || "");
setTaxCondition(profile.taxCondition || "MONOTRIBUTO");
setPointOfSale(profile.pointOfSale ? String(profile.pointOfSale) : "1");
const profileActivity = activities.find((activity) => activity.id === profile.activityCode);
setActivityCode(profile.activityCode || "");
setActivityDescription(profile.activityDescription || profileActivity?.name || "");
})
.catch((error: ApiError) => {
alert.showError(error.format());
})
.finally(() => {
setFiscalDataLoaded(true);
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
}, [SessionInfo.userId]);
useEffect(() => {
if (eventHandler.eventType !== EVENT_TYPES.SAVE) return;
if (!/^\d{11}$/.test(cuit)) {
alert.showError("El CUIT debe contener 11 dígitos.");
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
return;
}
if (!legalName.trim()) {
alert.showError("La razón social es requerida.");
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
return;
}
if (!Number(pointOfSale) || Number(pointOfSale) <= 0) {
alert.showError("El punto de venta debe ser mayor a 0.");
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
return;
}
if (activityCode.trim() && !arcaActivities.some((activity) => activity.id === activityCode.trim())) {
alert.showError("Seleccioná una actividad ARCA/CLAE válida.");
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
return;
}
eventHandler.setEventType(EVENT_TYPES.LOADING);
const selectedActivity = arcaActivities.find((activity) => activity.id === activityCode.trim());
upsertOrganizationFiscalProfile({
companyId: id,
cuit: cuit.trim(),
legalName: legalName.trim(),
taxCondition,
pointOfSale: Number(pointOfSale),
activityCode: activityCode.trim() || undefined,
activityDescription: selectedActivity?.name || activityDescription.trim() || undefined,
sessionUser: SessionInfo.userId,
})
.then((profile) => {
const savedActivity = arcaActivities.find((activity) => activity.id === profile.activityCode);
setActivityCode(profile.activityCode || "");
setActivityDescription(profile.activityDescription || savedActivity?.name || "");
alert.showSuccess("Datos fiscales actualizados.");
})
.catch((error: ApiError) => {
alert.showError(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
}, [eventHandler.eventType]);
return (
<>
<Location />
<div className={style.pageContainer}>
<section className={style.heroCard}>
<div className={style.heroIcon}>
<ReceiptLongOutlinedIcon />
</div>
<div>
<h2>Configuración fiscal</h2>
<p>
Estos datos se usan para emitir Factura C desde los movimientos de caja y para precargar el asistente ARCA.
</p>
</div>
</section>
<section className={style.formSection}>
<h3>Datos del emisor</h3>
<Grid2 container spacing={2}>
<Grid2 size={{ xs: 12, sm: 6 }}>
<Textbox
name="cuit"
placeholder="CUIT"
type="text"
value={cuit}
width="100%"
onChange={(event) => {
setCuit(event.target.value.replace(/\D/g, ""));
markNeedsSave();
}}
/>
</Grid2>
<Grid2 size={{ xs: 12, sm: 6 }}>
<Textbox
name="legalName"
placeholder="Razón social"
type="text"
value={legalName}
width="100%"
onChange={(event) => {
setLegalName(event.target.value);
markNeedsSave();
}}
/>
</Grid2>
</Grid2>
</section>
<section className={style.formSection}>
<div className={style.sectionHeader}>
<h3>Condición fiscal</h3>
<p>Seleccioná la condición declarada para esta organización.</p>
</div>
<div className={style.optionGroup} role="radiogroup" aria-label="Condición fiscal">
{fiscalConditionOptions.map((option) => {
const selected = taxCondition === option.id;
return (
<button
key={option.id}
type="button"
className={`${style.optionCard} ${selected ? style.optionCardSelected : ""}`}
onClick={() => {
setTaxCondition(option.id);
markNeedsSave();
}}
aria-pressed={selected}
>
<span className={style.optionTitle}>{option.title}</span>
<span className={style.optionDescription}>{option.description}</span>
</button>
);
})}
</div>
</section>
<section className={style.formSection}>
<h3>Facturación ARCA</h3>
<Grid2 container spacing={2}>
<Grid2 size={{ xs: 12, sm: 6 }}>
<Textbox
name="pointOfSale"
placeholder="Punto de venta"
type="text"
value={pointOfSale}
width="100%"
onChange={(event) => {
setPointOfSale(event.target.value.replace(/\D/g, ""));
markNeedsSave();
}}
/>
</Grid2>
</Grid2>
{fiscalDataLoaded && (
<TextObjectFilter
key={`${activityCode}-${arcaActivities.length}`}
placeholder="Actividad ARCA/CLAE"
content={activityFilterContent}
data={arcaActivities}
selectedId={activityCode}
onChange={(selectedActivityCode) => {
const selectedActivity = arcaActivities.find(
(activity) => activity.id === selectedActivityCode
);
setActivityCode(selectedActivity?.id || "");
setActivityDescription(selectedActivity?.name || "");
markNeedsSave();
}}
/>
)}
<p className={style.helpText}>
{activityCode && activityDescription
? `${activityCode} - ${activityDescription}`
: "Seleccioná la actividad principal declarada en ARCA."}
</p>
</section>
</div>
</>
);
}
@@ -17,6 +17,7 @@ import PendingActionsOutlinedIcon from "@mui/icons-material/PendingActionsOutlin
import TodayOutlinedIcon from "@mui/icons-material/TodayOutlined";
import PaymentsIcon from "@mui/icons-material/CurrencyExchangeOutlined";
import CashIcon from "@mui/icons-material/AccountBalanceOutlined";
import FiscalIcon from "@mui/icons-material/ReceiptLongOutlined";
import DiscountsIcon from "@mui/icons-material/LocalOfferOutlined";
import TemplatesIcon from "@mui/icons-material/SpeakerNotesOutlined";
import AssistanceIcon from "@mui/icons-material/HeadsetMicOutlined";
@@ -137,6 +138,11 @@ export default function OrganizationProfile() {
text: "Caja",
onClick: () => goTo("/admin/org/profile/" + id + "/cash"),
},
{
icon: <FiscalIcon />,
text: "Datos fiscales",
onClick: () => goTo("/admin/org/profile/" + id + "/fiscal"),
},
{
icon: <DiscountsIcon />,
text: "Descuentos",
@@ -185,6 +185,8 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [arcaLegalName, setArcaLegalName] = useState("");
const [arcaTaxCondition, setArcaTaxCondition] = useState<"MONOTRIBUTO" | "EXENTO">("MONOTRIBUTO");
const [arcaPointOfSale, setArcaPointOfSale] = useState("1");
const [arcaActivityCode, setArcaActivityCode] = useState("");
const [arcaActivityDescription, setArcaActivityDescription] = useState("");
const [arcaCsrPem, setArcaCsrPem] = useState("");
const [arcaCertificatePem, setArcaCertificatePem] = useState("");
const [arcaStatusLabel, setArcaStatusLabel] = useState("");
@@ -424,6 +426,8 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
setArcaLegalName(profile.legalName || "");
setArcaTaxCondition(profile.taxCondition || "MONOTRIBUTO");
setArcaPointOfSale(profile.pointOfSale ? String(profile.pointOfSale) : "1");
setArcaActivityCode(profile.activityCode || "");
setArcaActivityDescription(profile.activityDescription || "");
}).catch(console.error);
API.post<any[]>("arca-credentials/find", {
companyId: org.id || org._id,
@@ -825,6 +829,8 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
legalName: arcaLegalName,
taxCondition: arcaTaxCondition,
pointOfSale: Number(arcaPointOfSale),
activityCode: arcaActivityCode.trim() || undefined,
activityDescription: arcaActivityDescription.trim() || undefined,
sessionUser: SessionInfo.userId
});
handleNext();
@@ -968,6 +974,10 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
setTaxCondition={setArcaTaxCondition}
pointOfSale={arcaPointOfSale}
setPointOfSale={setArcaPointOfSale}
activityCode={arcaActivityCode}
setActivityCode={setArcaActivityCode}
activityDescription={arcaActivityDescription}
setActivityDescription={setArcaActivityDescription}
csrPem={arcaCsrPem}
certificatePem={arcaCertificatePem}
setCertificatePem={setArcaCertificatePem}
@@ -1,10 +1,17 @@
import React, { useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
import QuestionCard from "../../QuestionCard/QuestionCard";
import QuestionInput from "../../QuestionCard/QuestionInput";
import API from "@services/Api.Service";
type ArcaTaxCondition = "MONOTRIBUTO" | "EXENTO";
type ArcaActivityOption = {
id: string;
name: string;
description?: string;
};
type Props = {
currentStepIndex: number;
cuit: string;
@@ -15,6 +22,10 @@ type Props = {
setTaxCondition: (value: ArcaTaxCondition) => void;
pointOfSale: string;
setPointOfSale: (value: string) => void;
activityCode: string;
setActivityCode: (value: string) => void;
activityDescription: string;
setActivityDescription: (value: string) => void;
csrPem: string;
certificatePem: string;
setCertificatePem: (value: string) => void;
@@ -32,9 +43,74 @@ type Props = {
};
export default function ArcaOnboardingFlow(props: Props) {
const { currentStepIndex, cuit, setCuit, legalName, setLegalName, taxCondition, setTaxCondition, pointOfSale, setPointOfSale, csrPem, certificatePem, setCertificatePem, statusLabel, arcaLoading, arcaError, handleNext, handleSaveFiscalProfile, handleGenerateCsr, handleDownloadCsr, handleCertificateFile, handleUploadCertificate, handleEditFiscalProfile, handleFinish } = props;
const canSaveFiscalProfile = cuit.trim().length >= 11 && legalName.trim().length >= 3 && Number(pointOfSale) > 0;
const { currentStepIndex, cuit, setCuit, legalName, setLegalName, taxCondition, setTaxCondition, pointOfSale, setPointOfSale, activityCode, setActivityCode, activityDescription, setActivityDescription, csrPem, certificatePem, setCertificatePem, statusLabel, arcaLoading, arcaError, handleNext, handleSaveFiscalProfile, handleGenerateCsr, handleDownloadCsr, handleCertificateFile, handleUploadCertificate, handleEditFiscalProfile, handleFinish } = props;
const canSaveFiscalProfile = cuit.trim().length >= 11 && legalName.trim().length >= 3 && Number(pointOfSale) > 0 && /^\d{6}$/.test(activityCode.trim()) && activityDescription.trim().length > 0;
const [csrCopyStatus, setCsrCopyStatus] = useState<string | null>(null);
const [activities, setActivities] = useState<ArcaActivityOption[]>([]);
const [activitySearch, setActivitySearch] = useState("");
const [activitiesLoading, setActivitiesLoading] = useState(false);
const [activitiesLoaded, setActivitiesLoaded] = useState(false);
const [activitiesError, setActivitiesError] = useState<string | null>(null);
const [isActivityPickerOpen, setIsActivityPickerOpen] = useState(false);
useEffect(() => {
if (currentStepIndex !== 1 || activitiesLoaded || activitiesLoading) return;
setActivitiesLoading(true);
setActivitiesError(null);
API.post<ArcaActivityOption[]>("arca-activities/filter-all", {})
.then((response) => {
setActivities(response || []);
setActivitiesLoaded(true);
})
.catch((error) => {
console.error("Error loading ARCA activities:", error);
setActivitiesError("No pudimos cargar el catálogo de actividades. Volvé a intentar en unos segundos.");
})
.finally(() => setActivitiesLoading(false));
}, [activitiesLoaded, activitiesLoading, currentStepIndex]);
useEffect(() => {
if (activityCode && activityDescription && !activitySearch) {
setActivitySearch(`${activityCode} ${activityDescription}`);
}
}, [activityCode, activityDescription, activitySearch]);
const filteredActivities = useMemo(() => {
const normalizedSearch = normalizeActivityText(activitySearch);
const selectedCode = activityCode.trim();
if (!normalizedSearch) {
return activities.slice(0, 8);
}
const selectedActivity = activities.find((activity) => activity.id === selectedCode);
const matches = activities.filter((activity) => {
const searchableText = normalizeActivityText(`${activity.id} ${activity.name} ${activity.description || ""}`);
return normalizedSearch.split(" ").every((term) => searchableText.includes(term));
});
if (selectedActivity && !matches.some((activity) => activity.id === selectedActivity.id)) {
return [selectedActivity, ...matches].slice(0, 8);
}
return matches.slice(0, 8);
}, [activities, activityCode, activitySearch]);
const handleActivitySearchChange = (value: string) => {
setActivitySearch(value);
if (activityCode || activityDescription) {
setActivityCode("");
setActivityDescription("");
}
};
const handleActivitySelect = (activity: ArcaActivityOption) => {
setActivityCode(activity.id);
setActivityDescription(activity.name);
setActivitySearch(`${activity.id} ${activity.name}`);
setIsActivityPickerOpen(false);
};
const handleCopyCsr = async () => {
if (!csrPem) return;
@@ -59,9 +135,9 @@ export default function ArcaOnboardingFlow(props: Props) {
<QuestionInput label="Razón social" value={legalName} onChange={(event) => setLegalName(event.target.value)} placeholder="Nombre fiscal o razón social" />
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "10px" }}>
<OptionButton active={taxCondition === "MONOTRIBUTO"} onClick={() => setTaxCondition("MONOTRIBUTO")}>Monotributo</OptionButton>
<OptionButton active={taxCondition === "EXENTO"} onClick={() => setTaxCondition("EXENTO")}>Exento</OptionButton>
</div>
<QuestionInput label="Punto de venta" value={pointOfSale} onChange={(event) => setPointOfSale(event.target.value.replace(/\D/g, ""))} placeholder="Ejemplo: 1" inputMode="numeric" />
<ActivityPicker search={activitySearch} selectedCode={activityCode} selectedDescription={activityDescription} activities={filteredActivities} loading={activitiesLoading} error={activitiesError} isOpen={isActivityPickerOpen} onOpen={() => setIsActivityPickerOpen(true)} onClose={() => setIsActivityPickerOpen(false)} onSearchChange={handleActivitySearchChange} onSelect={handleActivitySelect} />
<StatusMessage error={arcaError} />
</div>
</QuestionCard>
@@ -122,6 +198,45 @@ function SecondaryButton({ onClick, children }: { onClick: () => void; children:
return <button type="button" onClick={onClick} style={{ padding: "12px 16px", borderRadius: "10px", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.16)", color: "white", cursor: "pointer", fontWeight: 700, fontFamily: "inherit" }}>{children}</button>;
}
function ActivityPicker({ search, selectedCode, selectedDescription, activities, loading, error, isOpen, onOpen, onClose, onSearchChange, onSelect }: { search: string; selectedCode: string; selectedDescription: string; activities: ArcaActivityOption[]; loading: boolean; error: string | null; isOpen: boolean; onOpen: () => void; onClose: () => void; onSearchChange: (value: string) => void; onSelect: (activity: ArcaActivityOption) => void; }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<label style={{ color: "rgba(255,255,255,0.82)", fontSize: "14px", fontWeight: 700 }}>Actividad ARCA/CLAE</label>
<button type="button" onClick={onOpen} style={{ width: "100%", textAlign: "left", padding: "12px 16px", borderRadius: "10px", border: selectedCode ? "1px solid var(--wine-red)" : "1px solid rgba(255,255,255,0.16)", background: selectedCode ? "rgba(255,42,127,0.16)" : "rgba(255,255,255,0.05)", color: selectedCode ? "white" : "rgba(255,255,255,0.58)", cursor: "pointer", fontWeight: 700, fontFamily: "inherit", lineHeight: 1.35 }}>
{selectedCode ? selectedDescription : "Seleccionar actividad"}
</button>
{isOpen && (
<div role="dialog" aria-modal="true" aria-label="Buscar actividad ARCA/CLAE" style={{ position: "fixed", inset: 0, zIndex: 3000, display: "flex", alignItems: "center", justifyContent: "center", padding: "18px", background: "rgba(0,0,0,0.62)" }} onClick={onClose}>
<div style={{ width: "min(620px, 100%)", maxHeight: "min(760px, 88vh)", display: "flex", flexDirection: "column", padding: "18px", borderRadius: "12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.12)", boxShadow: "0 28px 80px rgba(0,0,0,0.45)", backdropFilter: "blur(12px)" }} onClick={(event) => event.stopPropagation()}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "14px", alignItems: "flex-start", marginBottom: "14px" }}>
<div>
<h3 style={{ margin: 0, color: "white", fontSize: "20px" }}>Buscar actividad ARCA/CLAE</h3>
<p style={{ margin: "6px 0 0", color: "rgba(255,255,255,0.62)", fontSize: "13px", lineHeight: 1.4 }}>Buscá por código o nombre y seleccioná la actividad fiscal de la organización.</p>
</div>
<button type="button" onClick={onClose} aria-label="Cerrar buscador" style={{ width: "36px", height: "36px", borderRadius: "10px", border: "1px solid rgba(255,255,255,0.16)", background: "rgba(255,255,255,0.05)", color: "white", cursor: "pointer", fontSize: "18px" }}>×</button>
</div>
<input autoFocus value={search} onChange={(event) => onSearchChange(event.target.value)} placeholder="Ejemplo: 855000 o educación" style={{ width: "100%", boxSizing: "border-box", borderRadius: "12px", border: "1px solid rgba(255,255,255,0.16)", background: "rgba(255,255,255,0.05)", color: "white", padding: "14px 16px", fontFamily: "inherit", outline: "none" }} />
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginTop: "14px", overflowY: "auto", paddingRight: "2px" }}>
{loading && <InfoPanel>Cargando actividades ARCA...</InfoPanel>}
{error && <StatusMessage error={error} />}
{!loading && !error && activities.length === 0 && <InfoPanel>No encontramos actividades para esa búsqueda.</InfoPanel>}
{!loading && !error && activities.map((activity) => {
const isSelected = activity.id === selectedCode;
return (
<button key={activity.id} type="button" onClick={() => onSelect(activity)} style={{ display: "grid", gridTemplateColumns: "78px 1fr", gap: "12px", textAlign: "left", padding: "13px", borderRadius: "12px", border: isSelected ? "1px solid var(--wine-red)" : "1px solid rgba(255,255,255,0.12)", background: isSelected ? "rgba(255,42,127,0.16)" : "rgba(255,255,255,0.05)", color: "white", cursor: "pointer", fontFamily: "inherit" }}>
<strong style={{ fontSize: "13px" }}>{activity.id}</strong>
<span style={{ color: "rgba(255,255,255,0.78)", fontSize: "13px", lineHeight: 1.35 }}>{activity.name}</span>
</button>
);
})}
</div>
</div>
</div>
)}
</div>
);
}
function StepItem({ number, children }: { number: string; children: React.ReactNode }) {
return <div style={{ display: "flex", gap: "12px", alignItems: "flex-start", padding: "14px", borderRadius: "12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.12)", color: "rgba(255,255,255,0.78)", lineHeight: 1.5 }}><strong style={{ color: "white" }}>{number}.</strong><span>{children}</span></div>;
}
@@ -130,3 +245,12 @@ function StatusMessage({ error }: { error: string | null }) {
if (!error) return null;
return <div style={{ padding: "12px 14px", borderRadius: "10px", background: "rgba(255,60,60,0.1)", border: "1px solid rgba(255,60,60,0.3)", color: "#ff9999", fontSize: "13px", lineHeight: 1.4 }}>{error}</div>;
}
function normalizeActivityText(value: string) {
return value
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/\s+/g, " ")
.trim();
}