diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 429b20a..49d5456 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,15 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/centro-cobranza', + label: 'Centro de cobranza', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiCashCheck' in icon ? icon['mdiCashCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_PAYMENTS', + }, + { href: '/users/users-list', diff --git a/frontend/src/pages/centro-cobranza.tsx b/frontend/src/pages/centro-cobranza.tsx new file mode 100644 index 0000000..10ae971 --- /dev/null +++ b/frontend/src/pages/centro-cobranza.tsx @@ -0,0 +1,1893 @@ +import { + mdiAccountGroup, + mdiAlertCircle, + mdiBank, + mdiCalendarMonth, + mdiCashCheck, + mdiChevronRight, + mdiClockOutline, + mdiEmail, + mdiReceiptTextOutline, + mdiViewDashboardOutline, +} from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { + ReactElement, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; +import BaseIcon from '../components/BaseIcon'; +import CardBox from '../components/CardBox'; +import FormField from '../components/FormField'; +import LoadingSpinner from '../components/LoadingSpinner'; +import NotificationBar from '../components/NotificationBar'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; +import { hasPermission } from '../helpers/userPermissions'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { useAppSelector } from '../stores/hooks'; + +type CurrencyCode = 'VES' | 'USD'; + +type Apartment = { + id: string; + apartment_code?: string; + floor_number?: number; + unit_letter?: string; + occupancy_status?: string; + notes?: string; + building?: { + id?: string; + name?: string; + }; +}; + +type Person = { + id: string; + apartmentId?: string; + apartment?: Apartment; + first_name?: string; + last_name?: string; + person_role?: string; + phone?: string; + whatsapp?: string; + email?: string; + preferred_contact_channel?: string; + is_primary_contact?: boolean; + is_active?: boolean; +}; + +type MonthlyCharge = { + id: string; + apartmentId?: string; + apartment?: Apartment; + amount?: number | string; + currency?: CurrencyCode; + status?: string; + period_start?: string; + period_end?: string; + due_date?: string; + notes?: string; +}; + +type Account = { + id: string; + name?: string; + account_type?: 'bank' | 'cash'; + currency?: CurrencyCode; + institution?: string; + opening_balance?: number | string; + is_active?: boolean; +}; + +type PaymentMethod = { + id: string; + name?: string; + method_type?: string; + details?: string; + is_active?: boolean; +}; + +type Payment = { + id: string; + apartmentId?: string; + apartment?: Apartment; + chargeId?: string; + charge?: MonthlyCharge; + accountId?: string; + account?: Account; + amount?: number | string; + currency?: CurrencyCode; + status?: string; + paid_at?: string; + reference_code?: string; +}; + +type Receipt = { + id: string; + apartmentId?: string; + apartment?: Apartment; + paymentId?: string; + receipt_number?: string; + issued_at?: string; + delivery_status?: string; +}; + +type Expense = { + id: string; + expense_date?: string; + concept?: string; + vendor_name?: string; + amount?: number | string; + currency?: CurrencyCode; + accountId?: string; + account?: Account; +}; + +type CurrencyTotals = { + VES: number; + USD: number; +}; + +type CollectionData = { + apartments: Apartment[]; + people: Person[]; + charges: MonthlyCharge[]; + accounts: Account[]; + paymentMethods: PaymentMethod[]; + payments: Payment[]; + receipts: Receipt[]; + expenses: Expense[]; +}; + +type ApartmentSummary = { + apartment: Apartment; + residents: Person[]; + primaryContact?: Person; + pendingCharges: MonthlyCharge[]; + partiallyPaidCharges: MonthlyCharge[]; + recentPayments: Payment[]; + recentReceipts: Receipt[]; + outstandingByCurrency: CurrencyTotals; + overdueCount: number; + lastPaymentAt?: string; + totalOutstandingScore: number; +}; + +type SuccessState = { + apartmentCode: string; + receipts: Array<{ id: string; receipt_number: string }>; + payments: Array<{ id: string }>; +}; + +type BadgeTone = 'success' | 'warning' | 'danger' | 'info' | 'slate'; + +const EMPTY_TOTALS = (): CurrencyTotals => ({ VES: 0, USD: 0 }); + +const initialData: CollectionData = { + apartments: [], + people: [], + charges: [], + accounts: [], + paymentMethods: [], + payments: [], + receipts: [], + expenses: [], +}; + +const resolveRows = (payload: { data?: { rows?: T[] } } | undefined): T[] => { + if (Array.isArray(payload?.data?.rows)) { + return payload.data.rows; + } + + return []; +}; + +const toNumber = (value?: string | number | null) => { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const addCurrencyAmount = ( + totals: CurrencyTotals, + currency: CurrencyCode | undefined, + amount?: string | number, +) => { + if (!currency || (currency !== 'VES' && currency !== 'USD')) { + return totals; + } + + totals[currency] += toNumber(amount); + return totals; +}; + +const formatMoney = (amount: number, currency: CurrencyCode) => { + const locale = currency === 'VES' ? 'es-VE' : 'en-US'; + + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + maximumFractionDigits: 2, + }).format(amount || 0); +}; + +const formatDate = ( + value?: string, + options: Intl.DateTimeFormatOptions = { + day: '2-digit', + month: 'short', + year: 'numeric', + }, +) => { + if (!value) { + return '—'; + } + + const parsedDate = new Date(value); + + if (Number.isNaN(parsedDate.getTime())) { + return '—'; + } + + return new Intl.DateTimeFormat('es-VE', options).format(parsedDate); +}; + +const formatChargeLabel = (charge: MonthlyCharge) => { + if (!charge.period_start) { + return charge.notes || 'Cuota sin período'; + } + + const parsedDate = new Date(charge.period_start); + + if (Number.isNaN(parsedDate.getTime())) { + return charge.notes || 'Cuota sin período'; + } + + return new Intl.DateTimeFormat('es-VE', { + month: 'long', + year: 'numeric', + }).format(parsedDate); +}; + +const compareDatesAsc = (left?: string, right?: string) => { + const leftDate = left ? new Date(left).getTime() : 0; + const rightDate = right ? new Date(right).getTime() : 0; + return leftDate - rightDate; +}; + +const compareDatesDesc = (left?: string, right?: string) => { + const leftDate = left ? new Date(left).getTime() : 0; + const rightDate = right ? new Date(right).getTime() : 0; + return rightDate - leftDate; +}; + +const getApartmentCode = (apartment?: Apartment) => { + if (!apartment) { + return 'Sin código'; + } + + if (apartment.apartment_code) { + return apartment.apartment_code; + } + + const floor = apartment.floor_number + ? String(apartment.floor_number).padStart(2, '0') + : '--'; + const unit = apartment.unit_letter || ''; + + return `${floor}${unit}`; +}; + +const getPersonDisplayName = (person?: Person) => { + if (!person) { + return 'Sin contacto'; + } + + return [person.first_name, person.last_name].filter(Boolean).join(' ') || 'Sin contacto'; +}; + +const getBadgeClasses = (tone: BadgeTone) => { + const tones: Record = { + success: 'border-emerald-200 bg-emerald-50 text-emerald-700', + warning: 'border-amber-200 bg-amber-50 text-amber-700', + danger: 'border-rose-200 bg-rose-50 text-rose-700', + info: 'border-indigo-200 bg-indigo-50 text-indigo-700', + slate: 'border-slate-200 bg-slate-50 text-slate-700 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-100', + }; + + return tones[tone]; +}; + +const getChargeStatusTone = (status?: string): BadgeTone => { + switch (status) { + case 'paid': + return 'success'; + case 'partially_paid': + return 'warning'; + case 'waived': + return 'info'; + case 'pending': + return 'danger'; + default: + return 'slate'; + } +}; + +const getDeliveryTone = (status?: string): BadgeTone => { + switch (status) { + case 'delivered': + return 'success'; + case 'sent': + return 'info'; + case 'failed': + return 'danger'; + case 'not_sent': + return 'warning'; + default: + return 'slate'; + } +}; + +const resolveApiError = (error: unknown, fallback: string) => { + if (axios.isAxiosError(error)) { + const message = error.response?.data; + + if (typeof message === 'string' && message.trim()) { + return message; + } + + if (typeof message === 'object' && message && 'message' in message) { + return String(message.message); + } + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return fallback; +}; + +const Badge = ({ label, tone = 'slate' }: { label: string; tone?: BadgeTone }) => ( + + {label} + +); + +const DualCurrencyStack = ({ + totals, + showZero = false, + align = 'left', + large = false, +}: { + totals: CurrencyTotals; + showZero?: boolean; + align?: 'left' | 'right'; + large?: boolean; +}) => { + const entries = (['VES', 'USD'] as CurrencyCode[]).filter( + (currency) => showZero || totals[currency] > 0, + ); + + if (!entries.length) { + return

Sin movimientos

; + } + + return ( +
+ {entries.map((currency) => ( +
+ + {currency} + + + {formatMoney(totals[currency], currency)} + +
+ ))} +
+ ); +}; + +const SummaryCard = ({ + icon, + label, + helper, + children, +}: { + icon: string; + label: string; + helper: string; + children: ReactNode; +}) => ( + +
+
+

+ {label} +

+
{children}
+

{helper}

+
+
+ +
+
+
+); + +const CentroCobranza = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const [data, setData] = useState(initialData); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [loadError, setLoadError] = useState(''); + const [formError, setFormError] = useState(''); + const [successState, setSuccessState] = useState(null); + const [selectedApartmentId, setSelectedApartmentId] = useState(''); + const [selectedChargeIds, setSelectedChargeIds] = useState([]); + const [accountId, setAccountId] = useState(''); + const [paymentMethodId, setPaymentMethodId] = useState(''); + const [paidAt, setPaidAt] = useState(new Date().toISOString().slice(0, 10)); + const [referenceCode, setReferenceCode] = useState(''); + const [notes, setNotes] = useState(''); + + const canRegisterPayments = hasPermission(currentUser, 'CREATE_PAYMENTS'); + + const loadData = useCallback(async () => { + setIsLoading(true); + setLoadError(''); + + try { + const [ + apartmentsResponse, + peopleResponse, + chargesResponse, + accountsResponse, + paymentMethodsResponse, + paymentsResponse, + receiptsResponse, + expensesResponse, + ] = await Promise.all([ + axios.get('apartments?limit=500&field=apartment_code&sort=asc'), + axios.get('people?limit=2500&field=first_name&sort=asc'), + axios.get('monthly_charges?limit=10000'), + axios.get('accounts?limit=100'), + axios.get('payment_methods?limit=100'), + axios.get('payments?limit=5000'), + axios.get('receipts?limit=5000'), + axios.get('expenses?limit=5000'), + ]); + + setData({ + apartments: resolveRows(apartmentsResponse), + people: resolveRows(peopleResponse), + charges: resolveRows(chargesResponse), + accounts: resolveRows(accountsResponse), + paymentMethods: resolveRows(paymentMethodsResponse), + payments: resolveRows(paymentsResponse), + receipts: resolveRows(receiptsResponse), + expenses: resolveRows(expensesResponse), + }); + } catch (error) { + console.error('Collection center load failed:', error); + setLoadError( + resolveApiError( + error, + 'No pudimos cargar el centro de cobranza. Verifica tus permisos o vuelve a intentarlo.', + ), + ); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + const apartments = useMemo( + () => + [...data.apartments].sort((left, right) => + getApartmentCode(left).localeCompare(getApartmentCode(right)), + ), + [data.apartments], + ); + + const peopleByApartment = useMemo(() => { + const registry = new Map(); + + data.people.forEach((person) => { + const apartmentId = person.apartmentId || person.apartment?.id; + + if (!apartmentId) { + return; + } + + if (!registry.has(apartmentId)) { + registry.set(apartmentId, []); + } + + registry.get(apartmentId)?.push(person); + }); + + registry.forEach((residents) => { + residents.sort((left, right) => { + if (left.is_primary_contact === right.is_primary_contact) { + return getPersonDisplayName(left).localeCompare(getPersonDisplayName(right)); + } + + return left.is_primary_contact ? -1 : 1; + }); + }); + + return registry; + }, [data.people]); + + const chargesByApartment = useMemo(() => { + const registry = new Map(); + + data.charges.forEach((charge) => { + const apartmentId = charge.apartmentId || charge.apartment?.id; + + if (!apartmentId) { + return; + } + + if (!registry.has(apartmentId)) { + registry.set(apartmentId, []); + } + + registry.get(apartmentId)?.push(charge); + }); + + registry.forEach((charges) => charges.sort((left, right) => compareDatesAsc(left.due_date, right.due_date))); + + return registry; + }, [data.charges]); + + const paymentsByApartment = useMemo(() => { + const registry = new Map(); + + data.payments.forEach((payment) => { + const apartmentId = payment.apartmentId || payment.apartment?.id; + + if (!apartmentId) { + return; + } + + if (!registry.has(apartmentId)) { + registry.set(apartmentId, []); + } + + registry.get(apartmentId)?.push(payment); + }); + + registry.forEach((payments) => payments.sort((left, right) => compareDatesDesc(left.paid_at, right.paid_at))); + + return registry; + }, [data.payments]); + + const receiptsByApartment = useMemo(() => { + const registry = new Map(); + + data.receipts.forEach((receipt) => { + const apartmentId = receipt.apartmentId || receipt.apartment?.id; + + if (!apartmentId) { + return; + } + + if (!registry.has(apartmentId)) { + registry.set(apartmentId, []); + } + + registry.get(apartmentId)?.push(receipt); + }); + + registry.forEach((receipts) => receipts.sort((left, right) => compareDatesDesc(left.issued_at, right.issued_at))); + + return registry; + }, [data.receipts]); + + const apartmentSummaries = useMemo(() => { + return apartments + .map((apartment) => { + const residents = peopleByApartment.get(apartment.id) || []; + const primaryContact = + residents.find((person) => person.is_primary_contact) || + residents.find((person) => person.is_active) || + residents[0]; + const charges = chargesByApartment.get(apartment.id) || []; + const pendingCharges = charges.filter((charge) => charge.status === 'pending'); + const partiallyPaidCharges = charges.filter((charge) => charge.status === 'partially_paid'); + const outstandingByCurrency = [...pendingCharges, ...partiallyPaidCharges].reduce( + (totals, charge) => addCurrencyAmount(totals, charge.currency, charge.amount), + EMPTY_TOTALS(), + ); + const recentPayments = (paymentsByApartment.get(apartment.id) || []) + .filter((payment) => payment.status === 'confirmed') + .slice(0, 4); + const recentReceipts = (receiptsByApartment.get(apartment.id) || []).slice(0, 4); + const lastPaymentAt = recentPayments[0]?.paid_at; + + return { + apartment, + residents, + primaryContact, + pendingCharges, + partiallyPaidCharges, + recentPayments, + recentReceipts, + outstandingByCurrency, + overdueCount: pendingCharges.length + partiallyPaidCharges.length, + lastPaymentAt, + totalOutstandingScore: + pendingCharges.length * 1000 + + outstandingByCurrency.USD * 10 + + outstandingByCurrency.VES, + }; + }) + .sort((left, right) => right.totalOutstandingScore - left.totalOutstandingScore); + }, [apartments, chargesByApartment, paymentsByApartment, peopleByApartment, receiptsByApartment]); + + const overdueSummaries = useMemo( + () => apartmentSummaries.filter((summary) => summary.overdueCount > 0), + [apartmentSummaries], + ); + + useEffect(() => { + const preferredApartmentId = overdueSummaries[0]?.apartment.id || apartmentSummaries[0]?.apartment.id || ''; + + if (!preferredApartmentId) { + if (selectedApartmentId) { + setSelectedApartmentId(''); + } + + return; + } + + const stillExists = apartmentSummaries.some( + (summary) => summary.apartment.id === selectedApartmentId, + ); + + if (!selectedApartmentId || !stillExists) { + setSelectedApartmentId(preferredApartmentId); + } + }, [apartmentSummaries, overdueSummaries, selectedApartmentId]); + + const selectedSummary = useMemo( + () => + apartmentSummaries.find((summary) => summary.apartment.id === selectedApartmentId) || + apartmentSummaries[0], + [apartmentSummaries, selectedApartmentId], + ); + + const selectableCharges = useMemo( + () => selectedSummary?.pendingCharges || [], + [selectedSummary], + ); + + useEffect(() => { + const availableIds = new Set(selectableCharges.map((charge) => charge.id)); + + setSelectedChargeIds((currentIds) => { + const validIds = currentIds.filter((chargeId) => availableIds.has(chargeId)); + + if (validIds.length) { + return validIds; + } + + return selectableCharges[0] ? [selectableCharges[0].id] : []; + }); + }, [selectableCharges]); + + const selectedCharges = useMemo( + () => selectableCharges.filter((charge) => selectedChargeIds.includes(charge.id)), + [selectableCharges, selectedChargeIds], + ); + + const selectedCurrency = selectedCharges[0]?.currency; + + const activeAccounts = useMemo( + () => + data.accounts + .filter((account) => account.is_active) + .filter((account) => (selectedCurrency ? account.currency === selectedCurrency : true)) + .sort((left, right) => (left.name || '').localeCompare(right.name || '')), + [data.accounts, selectedCurrency], + ); + + const activePaymentMethods = useMemo( + () => + data.paymentMethods + .filter((method) => method.is_active) + .sort((left, right) => (left.name || '').localeCompare(right.name || '')), + [data.paymentMethods], + ); + + useEffect(() => { + const hasCurrentAccount = activeAccounts.some((account) => account.id === accountId); + + if (!hasCurrentAccount) { + setAccountId(activeAccounts[0]?.id || ''); + } + }, [accountId, activeAccounts]); + + useEffect(() => { + const hasCurrentPaymentMethod = activePaymentMethods.some( + (method) => method.id === paymentMethodId, + ); + + if (!hasCurrentPaymentMethod) { + setPaymentMethodId(activePaymentMethods[0]?.id || ''); + } + }, [activePaymentMethods, paymentMethodId]); + + const selectedPaymentMethod = useMemo( + () => activePaymentMethods.find((method) => method.id === paymentMethodId), + [activePaymentMethods, paymentMethodId], + ); + + const totalSelectedAmount = useMemo( + () => selectedCharges.reduce((sum, charge) => sum + toNumber(charge.amount), 0), + [selectedCharges], + ); + + const outstandingTotals = useMemo( + () => + apartmentSummaries.reduce((totals, summary) => { + totals.VES += summary.outstandingByCurrency.VES; + totals.USD += summary.outstandingByCurrency.USD; + return totals; + }, EMPTY_TOTALS()), + [apartmentSummaries], + ); + + const currentMonthCollections = useMemo(() => { + const today = new Date(); + + return data.payments + .filter((payment) => payment.status === 'confirmed' && payment.paid_at) + .reduce((totals, payment) => { + const paymentDate = payment.paid_at ? new Date(payment.paid_at) : null; + + if (!paymentDate || Number.isNaN(paymentDate.getTime())) { + return totals; + } + + if ( + paymentDate.getUTCFullYear() === today.getUTCFullYear() && + paymentDate.getUTCMonth() === today.getUTCMonth() + ) { + addCurrencyAmount(totals, payment.currency, payment.amount); + } + + return totals; + }, EMPTY_TOTALS()); + }, [data.payments]); + + const bankAndCashBalances = useMemo(() => { + const accountBalanceMap = new Map(); + + data.accounts.forEach((account) => { + accountBalanceMap.set(account.id, toNumber(account.opening_balance)); + }); + + data.payments + .filter((payment) => payment.status === 'confirmed') + .forEach((payment) => { + if (!payment.accountId) { + return; + } + + const account = data.accounts.find((item) => item.id === payment.accountId); + + if (!account || account.currency !== payment.currency) { + return; + } + + accountBalanceMap.set( + payment.accountId, + (accountBalanceMap.get(payment.accountId) || 0) + toNumber(payment.amount), + ); + }); + + data.expenses.forEach((expense) => { + if (!expense.accountId) { + return; + } + + const account = data.accounts.find((item) => item.id === expense.accountId); + + if (!account || account.currency !== expense.currency) { + return; + } + + accountBalanceMap.set( + expense.accountId, + (accountBalanceMap.get(expense.accountId) || 0) - toNumber(expense.amount), + ); + }); + + return data.accounts.reduce( + (buckets, account) => { + const balance = accountBalanceMap.get(account.id) || 0; + + if (account.account_type === 'cash') { + addCurrencyAmount(buckets.cash, account.currency, balance); + } else { + addCurrencyAmount(buckets.bank, account.currency, balance); + } + + return buckets; + }, + { + bank: EMPTY_TOTALS(), + cash: EMPTY_TOTALS(), + }, + ); + }, [data.accounts, data.expenses, data.payments]); + + const totalApartments = apartments.length; + const upToDateApartments = Math.max(totalApartments - overdueSummaries.length, 0); + const occupiedApartments = apartments.filter( + (apartment) => apartment.occupancy_status === 'occupied', + ).length; + const activeResidents = data.people.filter((person) => person.is_active).length; + + const recentReceipts = useMemo( + () => [...data.receipts].sort((left, right) => compareDatesDesc(left.issued_at, right.issued_at)).slice(0, 5), + [data.receipts], + ); + + const recentExpenses = useMemo( + () => [...data.expenses].sort((left, right) => compareDatesDesc(left.expense_date, right.expense_date)).slice(0, 4), + [data.expenses], + ); + + const needsSetup = + !data.apartments.length || + !data.charges.length || + !data.accounts.length || + !data.paymentMethods.length; + + const onboardingChecklist = [ + { + key: 'apartments', + label: 'Apartamentos', + isReady: data.apartments.length > 0, + href: '/apartments/apartments-list', + cta: data.apartments.length > 0 ? 'Ver apartamentos' : 'Crear apartamentos', + }, + { + key: 'people', + label: 'Residentes y contactos', + isReady: data.people.length > 0, + href: '/people/people-list', + cta: data.people.length > 0 ? 'Ver residentes' : 'Registrar residentes', + }, + { + key: 'charges', + label: 'Cuotas mensuales', + isReady: data.charges.length > 0, + href: '/monthly_charges/monthly_charges-list', + cta: data.charges.length > 0 ? 'Ver cuotas' : 'Crear cuotas', + }, + { + key: 'accounts', + label: 'Cuentas y métodos', + isReady: data.accounts.length > 0 && data.paymentMethods.length > 0, + href: '/accounts/accounts-list', + cta: + data.accounts.length > 0 && data.paymentMethods.length > 0 + ? 'Ver cuentas' + : 'Configurar cobros', + }, + ]; + + const toggleCharge = (charge: MonthlyCharge) => { + setFormError(''); + + setSelectedChargeIds((currentIds) => { + if (currentIds.includes(charge.id)) { + return currentIds.filter((chargeId) => chargeId !== charge.id); + } + + const currentCurrency = selectableCharges.find((item) => item.id === currentIds[0])?.currency; + + if (currentCurrency && charge.currency && currentCurrency !== charge.currency) { + setFormError( + 'Por ahora solo puedes registrar varios meses a la vez cuando todos están en la misma moneda.', + ); + return currentIds; + } + + return [...currentIds, charge.id].sort((leftId, rightId) => { + const leftCharge = selectableCharges.find((item) => item.id === leftId); + const rightCharge = selectableCharges.find((item) => item.id === rightId); + return compareDatesAsc(leftCharge?.due_date, rightCharge?.due_date); + }); + }); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setFormError(''); + setSuccessState(null); + + if (!canRegisterPayments) { + setFormError('Tu usuario no tiene permiso para registrar pagos.'); + return; + } + + if (!selectedSummary?.apartment.id) { + setFormError('Selecciona un apartamento antes de continuar.'); + return; + } + + if (!selectedChargeIds.length) { + setFormError('Selecciona al menos una cuota pendiente para generar el recibo.'); + return; + } + + if (!accountId) { + setFormError('Selecciona una cuenta de destino.'); + return; + } + + if (!paymentMethodId) { + setFormError('Selecciona un método de pago.'); + return; + } + + if (selectedPaymentMethod?.method_type !== 'cash' && !referenceCode.trim()) { + setFormError('La referencia es obligatoria para pagos distintos a efectivo.'); + return; + } + + setIsSubmitting(true); + + try { + const response = await axios.post('payments/register-batch', { + data: { + apartmentId: selectedSummary.apartment.id, + chargeIds: selectedChargeIds, + accountId, + paymentMethodId, + paidAt: new Date(`${paidAt}T12:00:00`).toISOString(), + referenceCode: referenceCode.trim(), + notes: notes.trim(), + }, + }); + + setSuccessState({ + apartmentCode: response.data?.apartment?.apartment_code || getApartmentCode(selectedSummary.apartment), + receipts: Array.isArray(response.data?.receipts) + ? response.data.receipts.map((receipt) => ({ + id: receipt.id, + receipt_number: receipt.receipt_number, + })) + : [], + payments: Array.isArray(response.data?.payments) + ? response.data.payments.map((payment) => ({ id: payment.id })) + : [], + }); + setReferenceCode(''); + setNotes(''); + await loadData(); + } catch (error) { + console.error('Batch payment registration failed:', error); + setFormError( + resolveApiError( + error, + 'No pudimos registrar el pago. Verifica los datos seleccionados e inténtalo de nuevo.', + ), + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <> + + {getPageTitle('Centro de cobranza')} + + + + + + + + + + + +
+
+
+

+ Flujo inicial listo +

+

+ Hola, {currentUser?.firstName || 'administración'}. +
+ Hoy puedes cobrar varios meses, generar recibos y ver el saldo del edificio. +

+

+ Esta primera entrega se concentra en el circuito más útil del condominio: + identificar morosidad por apartamento, registrar pagos completos por cuota + pendiente y dejar el recibo listo dentro del sistema. +

+
+ + + +
+
+
+
+
+

+ Saldo pendiente +

+
+ +
+
+
+

+ Cobrado este mes +

+
+ +
+
+
+

+ Banco +

+
+ +
+
+
+

+ Efectivo +

+
+ +
+
+
+
+
+
+ + {loadError ? ( + + {loadError} + + ) : null} + + {successState ? ( + +
+

+ Pago registrado para {successState.apartmentCode}. +

+

+ Se crearon {successState.payments.length} pago(s) y {successState.receipts.length}{' '} + recibo(s) listos para revisar o enviar desde administración. +

+
+ {successState.receipts.map((receipt) => ( + + {receipt.receipt_number} + + + ))} +
+
+
+ ) : null} + + {formError ? ( + + {formError} + + ) : null} + + {isLoading ? ( + + + + ) : null} + + {!isLoading ? ( +
+ +

+ {upToDateApartments}/{totalApartments} +

+

+ {overdueSummaries.length} con deuda registrada +

+
+ +

+ {occupiedApartments} +

+

+ apartamentos ocupados · {activeResidents} residentes/contactos activos +

+
+ + + + + + +
+ ) : null} + + {!isLoading && needsSetup ? ( +
+ +
+
+

+ Completa la base mínima para activar el flujo +

+

+ El centro de cobranza reutiliza los módulos ya existentes. Apenas tengas + apartamentos, cuotas, cuentas y métodos de pago, esta vista queda lista para + operar sin trabajo extra. +

+
+ + + + +
+ +
+ {onboardingChecklist.map((item) => ( +
+
+

{item.label}

+ +
+

+ {item.isReady + ? 'Ya puedes reutilizar este módulo dentro del flujo.' + : 'Configúralo una vez y quedará conectado al centro de cobranza.'} +

+ + {item.cta} + + +
+ ))} +
+
+
+ ) : null} + + {!isLoading && !needsSetup ? ( + <> +
+
+ +
+
+

+ Prioridad de cobranza por apartamento +

+

+ Haz clic sobre cualquier apartamento para ver sus residentes, cuotas + pendientes, pagos recientes y generar nuevos recibos desde la misma vista. +

+
+ + Revisar cargos mensuales + + +
+ +
+ {overdueSummaries.length ? ( + overdueSummaries.map((summary) => { + const isActive = summary.apartment.id === selectedSummary?.apartment.id; + + return ( + + ); + }) + ) : ( +
+

Todo el edificio está al día.

+

+ No encontramos cuotas pendientes. Puedes seguir registrando nuevos cargos o + revisar recibos y gastos desde los accesos rápidos. +

+
+ )} +
+
+
+ +
+ + {selectedSummary ? ( + <> +
+
+

+ Apartamento seleccionado +

+

+ {getApartmentCode(selectedSummary.apartment)} +

+

+ {selectedSummary.apartment.building?.name || 'Edificio principal'} +

+
+ + + + +
+ +
+
+
+
+

+ Contacto principal +

+

+ {getPersonDisplayName(selectedSummary.primaryContact)} +

+

+ {selectedSummary.primaryContact?.person_role || 'Sin rol definido'} +

+
+
+ +
+
+
+
+

+ Email +

+

+ {selectedSummary.primaryContact?.email || 'No registrado'} +

+
+
+

+ WhatsApp / teléfono +

+

+ {selectedSummary.primaryContact?.whatsapp || + selectedSummary.primaryContact?.phone || + 'No registrado'} +

+
+
+
+ +
+
+

+ Núcleo residente +

+ +
+
+ {selectedSummary.residents.length ? ( + selectedSummary.residents.map((person) => ( +
+ {getPersonDisplayName(person)} + + {person.person_role || 'sin rol'} + +
+ )) + ) : ( +

+ Aún no hay personas asociadas a este apartamento. +

+ )} +
+
+ +
+
+

+ Estado de la deuda +

+ +
+
+ {[...selectedSummary.pendingCharges, ...selectedSummary.partiallyPaidCharges].length ? ( + [...selectedSummary.pendingCharges, ...selectedSummary.partiallyPaidCharges].map((charge) => ( +
+
+

+ {formatChargeLabel(charge)} +

+

+ Vence {formatDate(charge.due_date)} +

+
+
+ + + {formatMoney(toNumber(charge.amount), charge.currency || 'VES')} + +
+
+ )) + ) : ( +
+ Este apartamento no tiene cuotas vencidas o pendientes. +
+ )} +
+
+ +
+
+

+ Últimos recibos +

+ + Ver todos + +
+
+ {selectedSummary.recentReceipts.length ? ( + selectedSummary.recentReceipts.map((receipt) => ( + +
+

+ {receipt.receipt_number || 'Recibo sin numeración'} +

+

+ Emitido {formatDate(receipt.issued_at)} +

+
+ + + )) + ) : ( +

+ Aún no hay recibos asociados a este apartamento. +

+ )} +
+
+
+ + ) : ( +
+ Selecciona un apartamento para ver su detalle. +
+ )} +
+
+
+ +
+
+ +
+
+

+ Registrar pago y generar recibos +

+

+ Selecciona cuotas pendientes del mismo apartamento y la misma moneda. El + sistema registra los pagos, marca las cuotas como pagadas y crea un recibo + por cada período cubierto. +

+
+ +
+ +
+
+
+ + + + +
+
+ + {selectedCurrency ? ( + + ) : null} +
+
+ {selectableCharges.length ? ( + selectableCharges.map((charge) => { + const isSelected = selectedChargeIds.includes(charge.id); + + return ( + + ); + }) + ) : ( +
+ El apartamento seleccionado no tiene cuotas pendientes para este flujo. +
+ )} +
+ {selectedSummary?.partiallyPaidCharges.length ? ( +
+ Hay {selectedSummary.partiallyPaidCharges.length} cuota(s) con abono + parcial. Revisa esos casos manualmente desde{' '} + + Cargos mensuales + {' '} + para no duplicar montos en esta primera iteración. +
+ ) : null} +
+ + + + + setPaidAt(event.target.value)} /> + + + + setReferenceCode(event.target.value)} + /> + + + +