feat: add AFIP Factura C issuance support to ArcaCredentials and update cash flow model
This commit is contained in:
@@ -72,6 +72,7 @@ export type DeleteByClientAccountMovementParams = {
|
||||
|
||||
export interface ICashFlow {
|
||||
id: string;
|
||||
_id?: string;
|
||||
companyId: string;
|
||||
paymentId?: string;
|
||||
clientId?: string;
|
||||
|
||||
@@ -26,6 +26,41 @@ export type ArcaWsfeLastVoucherResult = {
|
||||
lastVoucherNumber: number;
|
||||
};
|
||||
|
||||
export type CreateInvoiceFromCashMovementParams = {
|
||||
cashMovementId: string;
|
||||
billingDate: string | Date;
|
||||
paymentMethod: string;
|
||||
description: string;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type FindInvoicesParams = {
|
||||
companyId?: string;
|
||||
cashMovementId?: string;
|
||||
cashMovementIds?: string[];
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type InvoiceStatus = "PENDING_ARCA" | "APPROVED" | "PENDING_VERIFICATION" | "REJECTED" | "FAILED";
|
||||
|
||||
export type Invoice = {
|
||||
id?: string;
|
||||
cashMovementId: string;
|
||||
companyId: string;
|
||||
amount: number;
|
||||
type: "FACTURA_C";
|
||||
status: InvoiceStatus;
|
||||
billingDate: Date;
|
||||
paymentMethod: string;
|
||||
description: string;
|
||||
cae?: string;
|
||||
caeExpiresAt?: Date;
|
||||
pointOfSale?: number;
|
||||
voucherNumber?: number;
|
||||
voucherType?: string;
|
||||
arcaErrorMessage?: 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."),
|
||||
@@ -91,3 +126,13 @@ export const getArcaWsfeLastVoucher = async (
|
||||
): Promise<ArcaWsfeLastVoucherResult> => {
|
||||
return ApiRequest.post<ArcaWsfeLastVoucherResult>("arca-credentials/wsfe-last-voucher", data);
|
||||
};
|
||||
|
||||
export const createInvoiceFromCashMovement = async (
|
||||
data: CreateInvoiceFromCashMovementParams
|
||||
): Promise<Invoice> => {
|
||||
return ApiRequest.post<Invoice>("invoices/create-from-cash-movement", data);
|
||||
};
|
||||
|
||||
export const findInvoices = async (data: FindInvoicesParams): Promise<Invoice[]> => {
|
||||
return ApiRequest.post<Invoice[]>("invoices/find", data);
|
||||
};
|
||||
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
.movementsPage {
|
||||
padding: 16px;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.movementsList {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.movementCard {
|
||||
border: 1px solid var(--gray-light);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, var(--white) 0%, #f9fafb 100%);
|
||||
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.08);
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.movementCard::before {
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.9) 0%, transparent 68%);
|
||||
border-radius: 999px;
|
||||
content: "";
|
||||
height: 150px;
|
||||
position: absolute;
|
||||
right: -74px;
|
||||
top: -80px;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.movementCardIcon {
|
||||
color: var(--movement-card-accent, var(--gray-dark));
|
||||
height: 74px;
|
||||
opacity: 0.2;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
bottom: 14px;
|
||||
width: 74px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.movementContent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.movementCardCash {
|
||||
--movement-card-accent: #16803f;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0fbf4 100%);
|
||||
}
|
||||
|
||||
.movementCardBankTransfer {
|
||||
--movement-card-accent: #2563eb;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #eef5ff 100%);
|
||||
}
|
||||
|
||||
.movementCardCreditCard {
|
||||
--movement-card-accent: #7c3aed;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f5f0ff 100%);
|
||||
}
|
||||
|
||||
.movementCardDebitCard {
|
||||
--movement-card-accent: #0f766e;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #edfbf9 100%);
|
||||
}
|
||||
|
||||
.movementCardClientAccount {
|
||||
--movement-card-accent: #b45309;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fff7ed 100%);
|
||||
}
|
||||
|
||||
.movementCardOther {
|
||||
--movement-card-accent: #64748b;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.movementHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.movementSummary {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.movementDescription {
|
||||
color: var(--black-light);
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.movementDate {
|
||||
color: var(--gray-dark);
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.movementAmount {
|
||||
align-self: flex-start;
|
||||
border-radius: 999px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.movementAmountPositive {
|
||||
background: rgba(29, 141, 75, 0.12);
|
||||
color: var(--green-darkestX2);
|
||||
}
|
||||
|
||||
.movementAmountNegative {
|
||||
background: rgba(188, 32, 42, 0.12);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.movementMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.movementMetaItem {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
color: var(--gray-dark);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.movementActionsSlot {
|
||||
display: flex;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.movementInvoiceButton {
|
||||
background: var(--movement-card-accent, var(--black-light));
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: var(--white);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.movementInvoiceButton:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.movementInvoiceBadge {
|
||||
background: rgba(22, 128, 63, 0.12);
|
||||
border: 1px solid rgba(22, 128, 63, 0.24);
|
||||
border-radius: 999px;
|
||||
color: #166534;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
background: var(--white);
|
||||
border: 1px dashed var(--gray-light);
|
||||
border-radius: 18px;
|
||||
color: var(--gray-dark);
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.paginationWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.invoiceModalBackdrop {
|
||||
align-items: flex-end;
|
||||
background: rgba(15, 23, 42, 0.48);
|
||||
display: flex;
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
padding: 14px;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.invoiceModal {
|
||||
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;
|
||||
padding: 18px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.invoiceModalHeader {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.invoiceModalHeader h2 {
|
||||
color: var(--black-light);
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.invoiceModalClose {
|
||||
align-items: center;
|
||||
background: #f1f5f9;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: var(--gray-dark);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: 24px;
|
||||
height: 36px;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.invoiceAmountBox {
|
||||
background: linear-gradient(135deg, #f0fbf4 0%, #ffffff 100%);
|
||||
border: 1px solid rgba(22, 128, 63, 0.16);
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.invoiceAmountBox span {
|
||||
color: var(--gray-dark);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.invoiceAmountBox strong {
|
||||
color: var(--green-darkestX2);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.invoiceField {
|
||||
color: var(--black-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
gap: 7px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.invoiceField input,
|
||||
.invoiceField select,
|
||||
.invoiceField textarea {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--gray-light);
|
||||
border-radius: 14px;
|
||||
color: var(--black-light);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
padding: 11px 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.invoiceField textarea {
|
||||
min-height: 104px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.invoiceModalActions {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.invoiceCancelButton,
|
||||
.invoiceConfirmButton {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.invoiceCancelButton {
|
||||
background: #f1f5f9;
|
||||
color: var(--gray-dark);
|
||||
}
|
||||
|
||||
.invoiceConfirmButton {
|
||||
background: var(--green-darkestX2);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.invoiceCancelButton:disabled,
|
||||
.invoiceConfirmButton:disabled,
|
||||
.invoiceModalClose:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.movementsPage {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.movementsList {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.movementHeader {
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.movementAmount {
|
||||
align-self: auto;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.movementCardIcon {
|
||||
height: 82px;
|
||||
right: 28px;
|
||||
width: 82px;
|
||||
}
|
||||
|
||||
.invoiceModalBackdrop {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.invoiceModal {
|
||||
border-radius: 24px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.invoiceModalActions {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.movementsList {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
+439
-68
@@ -7,7 +7,11 @@ import { useLocationStore } from "@store/Location.Store";
|
||||
import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store";
|
||||
import AccountBalanceOutlinedIcon from "@mui/icons-material/AccountBalanceOutlined";
|
||||
import AccountMovementsIcon from "@mui/icons-material/CurrencyExchangeOutlined";
|
||||
import DataGrid, { DataGridColumn } from "@core/app/components/DataGrid/DataGrid";
|
||||
import AccountBalanceWalletOutlinedIcon from "@mui/icons-material/AccountBalanceWalletOutlined";
|
||||
import CreditCardOutlinedIcon from "@mui/icons-material/CreditCardOutlined";
|
||||
import MoreHorizOutlinedIcon from "@mui/icons-material/MoreHorizOutlined";
|
||||
import PaymentOutlinedIcon from "@mui/icons-material/PaymentOutlined";
|
||||
import PointOfSaleOutlinedIcon from "@mui/icons-material/PointOfSaleOutlined";
|
||||
import { formatPrice } from "@core/app/helpers/Numbers";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
@@ -15,7 +19,7 @@ import {
|
||||
PaginateCashFlowParams,
|
||||
PaginateCashFlowResults,
|
||||
} from "@core/Models/CashFlow.model";
|
||||
import { cashFlowPaginate } from "../Cash.Service";
|
||||
import { cashFlowPaginate, createInvoiceFromCashMovement, findInvoices, Invoice } 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";
|
||||
@@ -26,7 +30,18 @@ import { useFilterStore } from "@core/Store/Filter.Store";
|
||||
import { FindEmployeesParams } from "@core/Models/Collaborators.model";
|
||||
import filterObjects from "@components/TextObjectFilter/Filter.Service";
|
||||
import { FindClientsParams } from "@core/Models/Clients.model";
|
||||
import { paymentMethods, PAYMENT_METHODS } from "@core/Models/Payments.model";
|
||||
import {
|
||||
paymentMethods,
|
||||
PAYMENT_METHODS,
|
||||
PAYMENT_METHODS_CLIENT_ACCOUNT,
|
||||
} from "@core/Models/Payments.model";
|
||||
import {
|
||||
CLIENT_ACCOUNT_MOVEMENT_TYPES,
|
||||
CLIENT_ACCOUNT_PAYMENT_METHODS,
|
||||
} from "@core/Models/ClientAccountMovements.model";
|
||||
import PaginationControls from "@components/PaginationControls/PaginationControls";
|
||||
import { useAlert } from "@core/Store/Alert.Store";
|
||||
import style from "./page.module.css";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -161,6 +176,101 @@ const FilterContent = (props: FilterContentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const movementTypeLabels: Record<string, string> = {
|
||||
deposit: "Ingreso",
|
||||
withdrawal: "Egreso",
|
||||
};
|
||||
|
||||
const getPaymentMethodName = (paymentMethod: string) => {
|
||||
return paymentMethods.find((method) => method.id === paymentMethod)?.name || paymentMethod;
|
||||
};
|
||||
|
||||
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 isInvoicePaymentMethod = (paymentMethod?: string) => {
|
||||
return invoicePaymentMethodOptions.some((option) => option.id === paymentMethod);
|
||||
};
|
||||
|
||||
const getInvoicePaymentMethod = (paymentMethod?: string) => {
|
||||
if (paymentMethod && isInvoicePaymentMethod(paymentMethod)) {
|
||||
return paymentMethod;
|
||||
}
|
||||
|
||||
return CLIENT_ACCOUNT_PAYMENT_METHODS.CASH;
|
||||
};
|
||||
|
||||
type InvoiceDraft = {
|
||||
movement: ICashFlow;
|
||||
movementId: string;
|
||||
billingDate: string;
|
||||
paymentMethod: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const getPaymentMethodCardVisual = (paymentMethod?: PAYMENT_METHODS | string) => {
|
||||
switch (paymentMethod) {
|
||||
case CLIENT_ACCOUNT_PAYMENT_METHODS.CASH:
|
||||
return {
|
||||
Icon: PointOfSaleOutlinedIcon,
|
||||
className: style.movementCardCash,
|
||||
};
|
||||
case CLIENT_ACCOUNT_PAYMENT_METHODS.BANK_TRANSFER:
|
||||
return {
|
||||
Icon: AccountBalanceOutlinedIcon,
|
||||
className: style.movementCardBankTransfer,
|
||||
};
|
||||
case CLIENT_ACCOUNT_PAYMENT_METHODS.CREDIT_CARD:
|
||||
return {
|
||||
Icon: CreditCardOutlinedIcon,
|
||||
className: style.movementCardCreditCard,
|
||||
};
|
||||
case CLIENT_ACCOUNT_PAYMENT_METHODS.DEBIT_CARD:
|
||||
return {
|
||||
Icon: PaymentOutlinedIcon,
|
||||
className: style.movementCardDebitCard,
|
||||
};
|
||||
case PAYMENT_METHODS_CLIENT_ACCOUNT.CLIENT_ACCOUNT:
|
||||
return {
|
||||
Icon: AccountBalanceWalletOutlinedIcon,
|
||||
className: style.movementCardClientAccount,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
Icon: MoreHorizOutlinedIcon,
|
||||
className: style.movementCardOther,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getInvoiceStatusMessage = (invoice: Invoice) => {
|
||||
const voucher = invoice.pointOfSale && invoice.voucherNumber
|
||||
? ` Comprobante intentado: ${invoice.pointOfSale}-${invoice.voucherNumber}.`
|
||||
: "";
|
||||
|
||||
switch (invoice.status) {
|
||||
case "APPROVED":
|
||||
return "Factura emitida correctamente.";
|
||||
case "PENDING_ARCA":
|
||||
case "PENDING_VERIFICATION":
|
||||
return `${invoice.arcaErrorMessage || "La factura quedó pendiente de verificación en ARCA."}${voucher}`;
|
||||
case "REJECTED":
|
||||
case "FAILED":
|
||||
return invoice.arcaErrorMessage || "ARCA no autorizó la factura.";
|
||||
default:
|
||||
return "No se pudo confirmar el estado de la factura.";
|
||||
}
|
||||
};
|
||||
|
||||
export default function CashFlowMovementsList() {
|
||||
const data = useParams<UrlData>();
|
||||
const id = data.id;
|
||||
@@ -168,6 +278,7 @@ export default function CashFlowMovementsList() {
|
||||
const SessionInfo = useSessionStore();
|
||||
const location = useLocationStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const alert = useAlert();
|
||||
const filter = useFilterStore();
|
||||
|
||||
const [clientFilterData, setClientFilterData] = useState<TextObjectFilterResult[]>([]);
|
||||
@@ -181,52 +292,6 @@ export default function CashFlowMovementsList() {
|
||||
const [methodFilterData, setMethodFilterData] = useState<TextObjectFilterResult[]>([]);
|
||||
const [methodId, setMethodId] = useState<string>("");
|
||||
|
||||
const [dataGridColumns] = useState<DataGridColumn<ICashFlow>[]>([
|
||||
{
|
||||
field: "description",
|
||||
title: "Detalle",
|
||||
width: null,
|
||||
render: (value: ICashFlow) => (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: "10px",
|
||||
paddingRight: "10px",
|
||||
paddingTop: "10px",
|
||||
}}
|
||||
>
|
||||
{value.description}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: "10px",
|
||||
paddingRight: "10px",
|
||||
color: "var(--gray-dark)",
|
||||
}}
|
||||
>
|
||||
{dayjs(value.createdAt).format("DD MMMM YYYY - HH:mm")}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: "amount",
|
||||
title: "Importe",
|
||||
width: 150,
|
||||
render: (value: ICashFlow) => (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
textAlign: "right",
|
||||
color: value.amount > 0 ? "var(--green-darkestX2)" : "var(--red)",
|
||||
}}
|
||||
>
|
||||
{formatPrice(value.amount)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
const [pageSize] = useState<number>(
|
||||
parseInt(
|
||||
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||
@@ -235,6 +300,10 @@ export default function CashFlowMovementsList() {
|
||||
)
|
||||
);
|
||||
const [movements, setMovements] = useState<PaginateCashFlowResults>();
|
||||
const [invoicesByMovementId, setInvoicesByMovementId] = useState<Record<string, Invoice>>({});
|
||||
const [invoicingMovementId, setInvoicingMovementId] = useState<string>("");
|
||||
const [invoiceDraft, setInvoiceDraft] = useState<InvoiceDraft | null>(null);
|
||||
const [invoiceDraftsByMovementId, setInvoiceDraftsByMovementId] = useState<Record<string, InvoiceDraft>>({});
|
||||
const movementsRef = useRef(movements);
|
||||
|
||||
const loadMovements = (page: number = 1, pageSize: number = 30) => {
|
||||
@@ -262,6 +331,20 @@ export default function CashFlowMovementsList() {
|
||||
cashFlowPaginate(filterData)
|
||||
.then((res) => {
|
||||
setMovements(res);
|
||||
const movementIds = res.data
|
||||
.map((movement) => getMovementId(movement))
|
||||
.filter((movementId): movementId is string => Boolean(movementId));
|
||||
|
||||
if (movementIds.length > 0) {
|
||||
return findInvoices({ companyId: id, cashMovementIds: movementIds, sessionUser: SessionInfo.userId }).then((invoices) => {
|
||||
setInvoicesByMovementId((current) => ({
|
||||
...current,
|
||||
...Object.fromEntries(
|
||||
invoices.map((invoice) => [String(invoice.cashMovementId), invoice])
|
||||
),
|
||||
}));
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.CLEAR);
|
||||
@@ -345,6 +428,132 @@ export default function CashFlowMovementsList() {
|
||||
loadMovements(loadPage, pageSize);
|
||||
}, [clientId, collaboratorId, methodId]);
|
||||
|
||||
const renderMovementMeta = (movement: ICashFlow) => {
|
||||
const details = [
|
||||
movement.paymentMethod ? getPaymentMethodName(movement.paymentMethod) : null,
|
||||
movement.type ? movementTypeLabels[movement.type] || movement.type : null,
|
||||
movement.reference ? `Ref. ${movement.reference}` : null,
|
||||
].filter((detail): detail is string => Boolean(detail));
|
||||
|
||||
if (details.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.movementMeta}>
|
||||
{details.map((detail) => (
|
||||
<span key={detail} className={style.movementMetaItem}>
|
||||
{detail}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const canInvoiceMovement = (movement: ICashFlow) => {
|
||||
return movement.type === CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT && movement.amount > 0;
|
||||
};
|
||||
|
||||
const openInvoiceModal = (movement: ICashFlow) => {
|
||||
const movementId = getMovementId(movement);
|
||||
|
||||
if (!canInvoiceMovement(movement) || !movementId || invoicingMovementId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedDraft = invoiceDraftsByMovementId[movementId];
|
||||
|
||||
setInvoiceDraft(savedDraft ? { ...savedDraft, movement } : {
|
||||
movement,
|
||||
movementId,
|
||||
billingDate: dayjs(movement.createdAt).format("YYYY-MM-DD"),
|
||||
paymentMethod: getInvoicePaymentMethod(movement.paymentMethod),
|
||||
description: movement.description || "",
|
||||
});
|
||||
};
|
||||
|
||||
const closeInvoiceModal = () => {
|
||||
if (!invoicingMovementId) {
|
||||
setInvoiceDraftsByMovementId((current) => {
|
||||
if (!invoiceDraft) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const { [invoiceDraft.movementId]: _discarded, ...remaining } = current;
|
||||
return remaining;
|
||||
});
|
||||
setInvoiceDraft(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvoiceMovement = () => {
|
||||
if (!invoiceDraft || invoicingMovementId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!invoiceDraft.billingDate) {
|
||||
alert.showError("Debe seleccionar la fecha del comprobante.");
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
|
||||
setInvoicingMovementId(invoiceDraft.movementId);
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
|
||||
createInvoiceFromCashMovement({
|
||||
cashMovementId: invoiceDraft.movementId,
|
||||
billingDate: invoiceDraft.billingDate,
|
||||
paymentMethod: invoiceDraft.paymentMethod,
|
||||
description: invoiceDraft.description.trim(),
|
||||
sessionUser: SessionInfo.userId,
|
||||
})
|
||||
.then((invoice) => {
|
||||
setInvoicesByMovementId((current) => ({
|
||||
...current,
|
||||
[invoiceDraft.movementId]: invoice,
|
||||
}));
|
||||
|
||||
if (invoice.status === "APPROVED") {
|
||||
setInvoiceDraftsByMovementId((current) => {
|
||||
const { [invoiceDraft.movementId]: _discarded, ...remaining } = current;
|
||||
return remaining;
|
||||
});
|
||||
alert.showSuccess(getInvoiceStatusMessage(invoice));
|
||||
return;
|
||||
}
|
||||
|
||||
if (invoice.status === "REJECTED" || invoice.status === "FAILED") {
|
||||
setInvoiceDraftsByMovementId((current) => ({
|
||||
...current,
|
||||
[invoiceDraft.movementId]: invoiceDraft,
|
||||
}));
|
||||
}
|
||||
|
||||
alert.showError(getInvoiceStatusMessage(invoice));
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
setInvoiceDraftsByMovementId((current) => ({
|
||||
...current,
|
||||
[invoiceDraft.movementId]: invoiceDraft,
|
||||
}));
|
||||
alert.showError(error.message || "No se pudo emitir la factura.");
|
||||
})
|
||||
.finally(() => {
|
||||
setInvoicingMovementId("");
|
||||
setInvoiceDraft(null);
|
||||
eventHandler.setEventType(EVENT_TYPES.CLEAR);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Location />
|
||||
@@ -352,28 +561,190 @@ export default function CashFlowMovementsList() {
|
||||
|
||||
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "80px",
|
||||
left: "0px",
|
||||
right: "0px",
|
||||
bottom: "30px",
|
||||
}}
|
||||
>
|
||||
<DataGrid
|
||||
columns={dataGridColumns}
|
||||
data={movements ? movements.data : []}
|
||||
pagination={true}
|
||||
page={movements?.page}
|
||||
pages={movements?.pages}
|
||||
pageSize={pageSize}
|
||||
handleRefresh={loadMovements}
|
||||
handleRowClick={() => { }}
|
||||
/>
|
||||
<div className={style.movementsPage}>
|
||||
<section className={style.movementsList} aria-label="Movimientos de caja">
|
||||
{movements && movements.data.length === 0 && (
|
||||
<div className={style.emptyState}>
|
||||
No se encontraron movimientos con los filtros aplicados.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(movements ? movements.data : []).map((movement, index) => {
|
||||
const visual = getPaymentMethodCardVisual(movement.paymentMethod);
|
||||
const PaymentMethodIcon = visual.Icon;
|
||||
const movementId = getMovementId(movement);
|
||||
const movementKey = movementId || `${movement.createdAt}-${movement.reference}-${index}`;
|
||||
const invoice = movementId ? invoicesByMovementId[movementId] : undefined;
|
||||
const isInvoiced = invoice?.status === "APPROVED";
|
||||
const isPendingInvoice = invoice?.status === "PENDING_ARCA" || invoice?.status === "PENDING_VERIFICATION";
|
||||
|
||||
return (
|
||||
<article
|
||||
key={movementKey}
|
||||
className={`${style.movementCard} ${visual.className}`}
|
||||
>
|
||||
<PaymentMethodIcon
|
||||
className={style.movementCardIcon}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className={style.movementContent}>
|
||||
<div className={style.movementHeader}>
|
||||
<div className={style.movementSummary}>
|
||||
<h3 className={style.movementDescription}>
|
||||
{movement.description}
|
||||
</h3>
|
||||
<time className={style.movementDate}>
|
||||
{dayjs(movement.createdAt).format("DD MMMM YYYY - HH:mm")}
|
||||
</time>
|
||||
</div>
|
||||
<div
|
||||
className={`${style.movementAmount} ${
|
||||
movement.amount > 0
|
||||
? style.movementAmountPositive
|
||||
: style.movementAmountNegative
|
||||
}`}
|
||||
>
|
||||
{formatPrice(movement.amount)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderMovementMeta(movement)}
|
||||
|
||||
<div className={style.movementActionsSlot}>
|
||||
{isInvoiced ? (
|
||||
<span className={style.movementInvoiceBadge}>
|
||||
Factura C {invoice?.pointOfSale}-{invoice?.voucherNumber}
|
||||
</span>
|
||||
) : isPendingInvoice ? (
|
||||
<span className={style.movementInvoiceBadge} title={getInvoiceStatusMessage(invoice)}>
|
||||
Factura pendiente
|
||||
</span>
|
||||
) : !canInvoiceMovement(movement) ? null : (
|
||||
<button
|
||||
type="button"
|
||||
className={style.movementInvoiceButton}
|
||||
disabled={invoicingMovementId === movementId}
|
||||
onClick={() => openInvoiceModal(movement)}
|
||||
>
|
||||
{invoicingMovementId === movementId ? "Facturando..." : "Facturar"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
{movements && movements.pages > 1 && (
|
||||
<div className={style.paginationWrapper}>
|
||||
<PaginationControls
|
||||
current={movements.page}
|
||||
size={pageSize}
|
||||
max={movements.pages}
|
||||
onChange={loadMovements}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "15px" }}></div>
|
||||
|
||||
{invoiceDraft && (
|
||||
<div className={style.invoiceModalBackdrop} role="presentation">
|
||||
<section
|
||||
className={style.invoiceModal}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="invoice-modal-title"
|
||||
>
|
||||
<div className={style.invoiceModalHeader}>
|
||||
<h2 id="invoice-modal-title">Emitir Factura C</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={style.invoiceModalClose}
|
||||
onClick={closeInvoiceModal}
|
||||
disabled={Boolean(invoicingMovementId)}
|
||||
aria-label="Cerrar"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<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.invoiceModalActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={style.invoiceCancelButton}
|
||||
onClick={closeInvoiceModal}
|
||||
disabled={Boolean(invoicingMovementId)}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={style.invoiceConfirmButton}
|
||||
onClick={handleInvoiceMovement}
|
||||
disabled={Boolean(invoicingMovementId)}
|
||||
>
|
||||
{invoicingMovementId ? "Facturando..." : "Emitir factura"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</SecureByPlan>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user