39895-vm/frontend/src/pages/centro-cobranza.tsx
Flatlogic Bot fca450d5e9 1.2
2026-05-06 12:13:41 +00:00

1894 lines
75 KiB
TypeScript

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 = <T,>(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<BadgeTone, string> = {
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 }) => (
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold ${getBadgeClasses(
tone,
)}`}
>
{label}
</span>
);
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 <p className="text-sm text-slate-500 dark:text-slate-300">Sin movimientos</p>;
}
return (
<div
className={`flex flex-col gap-1 ${
align === 'right' ? 'items-start text-left md:items-end md:text-right' : 'items-start'
}`}
>
{entries.map((currency) => (
<div key={currency} className="flex items-baseline gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400 dark:text-slate-300">
{currency}
</span>
<span
className={`${
large ? 'text-lg font-bold' : 'text-sm font-semibold'
} text-slate-900 dark:text-white`}
>
{formatMoney(totals[currency], currency)}
</span>
</div>
))}
</div>
);
};
const SummaryCard = ({
icon,
label,
helper,
children,
}: {
icon: string;
label: string;
helper: string;
children: ReactNode;
}) => (
<CardBox className="h-full">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-300">
{label}
</p>
<div className="mt-4">{children}</div>
<p className="mt-4 text-sm text-slate-500 dark:text-slate-300">{helper}</p>
</div>
<div className="rounded-2xl bg-slate-100 p-3 text-indigo-600 dark:bg-dark-800 dark:text-pavitra-blue">
<BaseIcon path={icon} size={24} />
</div>
</div>
</CardBox>
);
const CentroCobranza = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const [data, setData] = useState<CollectionData>(initialData);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [loadError, setLoadError] = useState('');
const [formError, setFormError] = useState('');
const [successState, setSuccessState] = useState<SuccessState | null>(null);
const [selectedApartmentId, setSelectedApartmentId] = useState('');
const [selectedChargeIds, setSelectedChargeIds] = useState<string[]>([]);
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<Apartment>(apartmentsResponse),
people: resolveRows<Person>(peopleResponse),
charges: resolveRows<MonthlyCharge>(chargesResponse),
accounts: resolveRows<Account>(accountsResponse),
paymentMethods: resolveRows<PaymentMethod>(paymentMethodsResponse),
payments: resolveRows<Payment>(paymentsResponse),
receipts: resolveRows<Receipt>(receiptsResponse),
expenses: resolveRows<Expense>(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<string, Person[]>();
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<string, MonthlyCharge[]>();
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<string, Payment[]>();
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<string, Receipt[]>();
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<ApartmentSummary[]>(() => {
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<string, number>();
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<HTMLFormElement>) => {
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 (
<>
<Head>
<title>{getPageTitle('Centro de cobranza')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiCashCheck} title='centro de cobranza' main>
<BaseButtons className='mt-4 md:mt-0'>
<BaseButton href='/payments/payments-list' label='Ver pagos' color='whiteDark' small />
<BaseButton href='/receipts/receipts-list' label='Recibos' color='whiteDark' small />
<BaseButton href='/expenses/expenses-list' label='Gastos' color='info' small />
</BaseButtons>
</SectionTitleLineWithButton>
<div className='mb-6 rounded-[28px] bg-gradient-to-br from-slate-950 via-indigo-900 to-indigo-700 p-6 text-white shadow-2xl shadow-indigo-900/20'>
<div className='grid gap-6 xl:grid-cols-12 xl:items-center'>
<div className='xl:col-span-7'>
<p className='text-sm font-semibold uppercase tracking-[0.35em] text-indigo-200'>
Flujo inicial listo
</p>
<h2 className='mt-3 text-3xl font-semibold tracking-tight md:text-4xl'>
Hola, {currentUser?.firstName || 'administración'}.
<br />
Hoy puedes cobrar varios meses, generar recibos y ver el saldo del edificio.
</h2>
<p className='mt-4 max-w-3xl text-base leading-7 text-indigo-100/90'>
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.
</p>
<div className='mt-6 flex flex-wrap gap-3'>
<Badge label={`${overdueSummaries.length} apartamentos con deuda`} tone='warning' />
<Badge label={`${activeResidents} contactos activos`} tone='info' />
<Badge label={`${recentReceipts.length} recibos recientes`} tone='success' />
</div>
</div>
<div className='xl:col-span-5'>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-3xl border border-white/15 bg-white/10 p-4 backdrop-blur'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-indigo-100/80'>
Saldo pendiente
</p>
<div className='mt-3'>
<DualCurrencyStack totals={outstandingTotals} showZero large />
</div>
</div>
<div className='rounded-3xl border border-white/15 bg-white/10 p-4 backdrop-blur'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-indigo-100/80'>
Cobrado este mes
</p>
<div className='mt-3'>
<DualCurrencyStack totals={currentMonthCollections} showZero large />
</div>
</div>
<div className='rounded-3xl border border-white/15 bg-white/10 p-4 backdrop-blur'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-indigo-100/80'>
Banco
</p>
<div className='mt-3'>
<DualCurrencyStack totals={bankAndCashBalances.bank} showZero large />
</div>
</div>
<div className='rounded-3xl border border-white/15 bg-white/10 p-4 backdrop-blur'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-indigo-100/80'>
Efectivo
</p>
<div className='mt-3'>
<DualCurrencyStack totals={bankAndCashBalances.cash} showZero large />
</div>
</div>
</div>
</div>
</div>
</div>
{loadError ? (
<NotificationBar color='danger' icon={mdiAlertCircle}>
{loadError}
</NotificationBar>
) : null}
{successState ? (
<NotificationBar color='success' icon={mdiReceiptTextOutline}>
<div>
<p className='font-semibold'>
Pago registrado para {successState.apartmentCode}.
</p>
<p className='mt-1 text-sm'>
Se crearon {successState.payments.length} pago(s) y {successState.receipts.length}{' '}
recibo(s) listos para revisar o enviar desde administración.
</p>
<div className='mt-3 flex flex-wrap gap-2'>
{successState.receipts.map((receipt) => (
<Link
key={receipt.id}
href={`/receipts/${receipt.id}`}
className='inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-white px-3 py-1 text-xs font-semibold text-emerald-700 transition hover:border-emerald-300 hover:bg-emerald-50'
>
{receipt.receipt_number}
<BaseIcon path={mdiChevronRight} size={14} />
</Link>
))}
</div>
</div>
</NotificationBar>
) : null}
{formError ? (
<NotificationBar color='warning' icon={mdiAlertCircle}>
{formError}
</NotificationBar>
) : null}
{isLoading ? (
<CardBox>
<LoadingSpinner />
</CardBox>
) : null}
{!isLoading ? (
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<SummaryCard
icon={mdiViewDashboardOutline}
label='Estado del edificio'
helper='Apartamentos al día vs. morosidad detectada en cuotas pendientes.'
>
<p className='text-3xl font-semibold text-slate-900 dark:text-white'>
{upToDateApartments}/{totalApartments}
</p>
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
{overdueSummaries.length} con deuda registrada
</p>
</SummaryCard>
<SummaryCard
icon={mdiAccountGroup}
label='Ocupación y contactos'
helper='Capacidad habitada y base activa para cobros, avisos y recibos.'
>
<p className='text-3xl font-semibold text-slate-900 dark:text-white'>
{occupiedApartments}
</p>
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
apartamentos ocupados · {activeResidents} residentes/contactos activos
</p>
</SummaryCard>
<SummaryCard
icon={mdiCalendarMonth}
label='Saldo pendiente'
helper='Total acumulado por cuotas pendientes o con abono parcial.'
>
<DualCurrencyStack totals={outstandingTotals} showZero large />
</SummaryCard>
<SummaryCard
icon={mdiBank}
label='Disponibilidad'
helper='Caja y bancos calculados con saldo inicial + pagos confirmados - gastos.'
>
<DualCurrencyStack
totals={{
VES: bankAndCashBalances.bank.VES + bankAndCashBalances.cash.VES,
USD: bankAndCashBalances.bank.USD + bankAndCashBalances.cash.USD,
}}
showZero
large
/>
</SummaryCard>
</div>
) : null}
{!isLoading && needsSetup ? (
<div className='mt-6'>
<CardBox>
<div className='flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between'>
<div className='max-w-2xl'>
<h3 className='text-2xl font-semibold text-slate-900 dark:text-white'>
Completa la base mínima para activar el flujo
</h3>
<p className='mt-2 text-sm leading-7 text-slate-500 dark:text-slate-300'>
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.
</p>
</div>
<BaseButtons className='justify-start lg:justify-end'>
<BaseButton href='/apartments/apartments-new' label='Nuevo apartamento' color='whiteDark' small />
<BaseButton href='/monthly_charges/monthly_charges-new' label='Nueva cuota' color='info' small />
</BaseButtons>
</div>
<div className='mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
{onboardingChecklist.map((item) => (
<div
key={item.key}
className='rounded-3xl border border-slate-200 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800'
>
<div className='flex items-center justify-between gap-3'>
<p className='font-semibold text-slate-900 dark:text-white'>{item.label}</p>
<Badge label={item.isReady ? 'Listo' : 'Pendiente'} tone={item.isReady ? 'success' : 'warning'} />
</div>
<p className='mt-3 text-sm text-slate-500 dark:text-slate-300'>
{item.isReady
? 'Ya puedes reutilizar este módulo dentro del flujo.'
: 'Configúralo una vez y quedará conectado al centro de cobranza.'}
</p>
<Link
href={item.href}
className='mt-4 inline-flex items-center gap-2 text-sm font-semibold text-indigo-600 transition hover:text-indigo-700 dark:text-pavitra-blue'
>
{item.cta}
<BaseIcon path={mdiChevronRight} size={16} />
</Link>
</div>
))}
</div>
</CardBox>
</div>
) : null}
{!isLoading && !needsSetup ? (
<>
<div className='mt-6 grid gap-6 xl:grid-cols-12'>
<div className='xl:col-span-7'>
<CardBox>
<div className='flex flex-col gap-4 md:flex-row md:items-end md:justify-between'>
<div>
<h3 className='text-2xl font-semibold text-slate-900 dark:text-white'>
Prioridad de cobranza por apartamento
</h3>
<p className='mt-2 text-sm leading-7 text-slate-500 dark:text-slate-300'>
Haz clic sobre cualquier apartamento para ver sus residentes, cuotas
pendientes, pagos recientes y generar nuevos recibos desde la misma vista.
</p>
</div>
<Link
href='/monthly_charges/monthly_charges-list'
className='inline-flex items-center gap-2 text-sm font-semibold text-indigo-600 transition hover:text-indigo-700 dark:text-pavitra-blue'
>
Revisar cargos mensuales
<BaseIcon path={mdiChevronRight} size={16} />
</Link>
</div>
<div className='mt-6 space-y-3'>
{overdueSummaries.length ? (
overdueSummaries.map((summary) => {
const isActive = summary.apartment.id === selectedSummary?.apartment.id;
return (
<button
key={summary.apartment.id}
type='button'
onClick={() => setSelectedApartmentId(summary.apartment.id)}
className={`w-full rounded-3xl border p-4 text-left transition-all ${
isActive
? 'border-indigo-300 bg-indigo-50 shadow-lg shadow-indigo-100 dark:border-pavitra-blue dark:bg-dark-800'
: 'border-slate-200 bg-white hover:border-indigo-200 hover:bg-slate-50 dark:border-dark-700 dark:bg-dark-900 dark:hover:bg-dark-800'
}`}
>
<div className='flex flex-col gap-4 md:flex-row md:items-start md:justify-between'>
<div>
<div className='flex flex-wrap items-center gap-2'>
<h4 className='text-lg font-semibold text-slate-900 dark:text-white'>
{getApartmentCode(summary.apartment)}
</h4>
<Badge label={`${summary.overdueCount} cuota(s) pendientes`} tone='warning' />
</div>
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
{summary.primaryContact
? `${getPersonDisplayName(summary.primaryContact)} · ${summary.primaryContact.email || summary.primaryContact.whatsapp || summary.primaryContact.phone || 'sin dato de contacto'}`
: 'Sin contacto principal asignado'}
</p>
<div className='mt-3 flex flex-wrap gap-2'>
<Badge
label={
summary.apartment.occupancy_status === 'occupied'
? 'Ocupado'
: summary.apartment.occupancy_status === 'vacant'
? 'Vacío'
: 'En mantenimiento'
}
tone='info'
/>
<Badge
label={`${summary.residents.length} integrante(s)`}
tone='slate'
/>
<Badge
label={
summary.lastPaymentAt
? `Último pago ${formatDate(summary.lastPaymentAt)}`
: 'Sin pagos confirmados'
}
tone={summary.lastPaymentAt ? 'success' : 'danger'}
/>
</div>
</div>
<div className='md:text-right'>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-400 dark:text-slate-300'>
Saldo por cobrar
</p>
<div className='mt-3'>
<DualCurrencyStack totals={summary.outstandingByCurrency} showZero align='right' large />
</div>
</div>
</div>
</button>
);
})
) : (
<div className='rounded-3xl border border-emerald-200 bg-emerald-50 p-6 text-emerald-700'>
<p className='text-lg font-semibold'>Todo el edificio está al día.</p>
<p className='mt-2 text-sm'>
No encontramos cuotas pendientes. Puedes seguir registrando nuevos cargos o
revisar recibos y gastos desde los accesos rápidos.
</p>
</div>
)}
</div>
</CardBox>
</div>
<div className='xl:col-span-5'>
<CardBox>
{selectedSummary ? (
<>
<div className='flex flex-col gap-4 border-b border-slate-200 pb-4 dark:border-dark-700 md:flex-row md:items-start md:justify-between'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-300'>
Apartamento seleccionado
</p>
<h3 className='mt-2 text-2xl font-semibold text-slate-900 dark:text-white'>
{getApartmentCode(selectedSummary.apartment)}
</h3>
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
{selectedSummary.apartment.building?.name || 'Edificio principal'}
</p>
</div>
<BaseButtons className='justify-start md:justify-end'>
<BaseButton
href={`/apartments/${selectedSummary.apartment.id}`}
label='Detalle'
color='whiteDark'
small
/>
<BaseButton href='/people/people-list' label='Residentes' color='info' small />
</BaseButtons>
</div>
<div className='mt-6 space-y-6'>
<div className='rounded-3xl border border-slate-200 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800'>
<div className='flex items-start justify-between gap-4'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-300'>
Contacto principal
</p>
<h4 className='mt-2 text-lg font-semibold text-slate-900 dark:text-white'>
{getPersonDisplayName(selectedSummary.primaryContact)}
</h4>
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
{selectedSummary.primaryContact?.person_role || 'Sin rol definido'}
</p>
</div>
<div className='rounded-2xl bg-white p-3 text-indigo-600 dark:bg-dark-900 dark:text-pavitra-blue'>
<BaseIcon path={mdiEmail} size={22} />
</div>
</div>
<div className='mt-4 grid gap-3 sm:grid-cols-2'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400 dark:text-slate-300'>
Email
</p>
<p className='mt-1 text-sm text-slate-700 dark:text-slate-100'>
{selectedSummary.primaryContact?.email || 'No registrado'}
</p>
</div>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400 dark:text-slate-300'>
WhatsApp / teléfono
</p>
<p className='mt-1 text-sm text-slate-700 dark:text-slate-100'>
{selectedSummary.primaryContact?.whatsapp ||
selectedSummary.primaryContact?.phone ||
'No registrado'}
</p>
</div>
</div>
</div>
<div>
<div className='flex items-center justify-between gap-3'>
<h4 className='text-lg font-semibold text-slate-900 dark:text-white'>
Núcleo residente
</h4>
<Badge
label={`${selectedSummary.residents.length} persona(s)`}
tone='slate'
/>
</div>
<div className='mt-3 flex flex-wrap gap-2'>
{selectedSummary.residents.length ? (
selectedSummary.residents.map((person) => (
<div
key={person.id}
className='rounded-full border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-100'
>
<span className='font-semibold'>{getPersonDisplayName(person)}</span>
<span className='ml-2 text-slate-500 dark:text-slate-300'>
{person.person_role || 'sin rol'}
</span>
</div>
))
) : (
<p className='text-sm text-slate-500 dark:text-slate-300'>
Aún no hay personas asociadas a este apartamento.
</p>
)}
</div>
</div>
<div>
<div className='flex items-center justify-between gap-3'>
<h4 className='text-lg font-semibold text-slate-900 dark:text-white'>
Estado de la deuda
</h4>
<DualCurrencyStack totals={selectedSummary.outstandingByCurrency} showZero align='right' />
</div>
<div className='mt-3 space-y-2'>
{[...selectedSummary.pendingCharges, ...selectedSummary.partiallyPaidCharges].length ? (
[...selectedSummary.pendingCharges, ...selectedSummary.partiallyPaidCharges].map((charge) => (
<div
key={charge.id}
className='flex flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-3 dark:border-dark-700 dark:bg-dark-800 sm:flex-row sm:items-center sm:justify-between'
>
<div>
<p className='font-semibold text-slate-900 dark:text-white'>
{formatChargeLabel(charge)}
</p>
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
Vence {formatDate(charge.due_date)}
</p>
</div>
<div className='flex items-center gap-3'>
<Badge
label={charge.status === 'partially_paid' ? 'Abono parcial' : 'Pendiente'}
tone={getChargeStatusTone(charge.status)}
/>
<span className='text-sm font-semibold text-slate-900 dark:text-white'>
{formatMoney(toNumber(charge.amount), charge.currency || 'VES')}
</span>
</div>
</div>
))
) : (
<div className='rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-700'>
Este apartamento no tiene cuotas vencidas o pendientes.
</div>
)}
</div>
</div>
<div>
<div className='flex items-center justify-between gap-3'>
<h4 className='text-lg font-semibold text-slate-900 dark:text-white'>
Últimos recibos
</h4>
<Link
href='/receipts/receipts-list'
className='text-sm font-semibold text-indigo-600 transition hover:text-indigo-700 dark:text-pavitra-blue'
>
Ver todos
</Link>
</div>
<div className='mt-3 space-y-2'>
{selectedSummary.recentReceipts.length ? (
selectedSummary.recentReceipts.map((receipt) => (
<Link
key={receipt.id}
href={`/receipts/${receipt.id}`}
className='flex items-center justify-between rounded-2xl border border-slate-200 bg-white p-3 transition hover:border-indigo-200 hover:bg-slate-50 dark:border-dark-700 dark:bg-dark-800 dark:hover:bg-dark-700'
>
<div>
<p className='font-semibold text-slate-900 dark:text-white'>
{receipt.receipt_number || 'Recibo sin numeración'}
</p>
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
Emitido {formatDate(receipt.issued_at)}
</p>
</div>
<Badge
label={receipt.delivery_status || 'sin estado'}
tone={getDeliveryTone(receipt.delivery_status)}
/>
</Link>
))
) : (
<p className='text-sm text-slate-500 dark:text-slate-300'>
Aún no hay recibos asociados a este apartamento.
</p>
)}
</div>
</div>
</div>
</>
) : (
<div className='py-10 text-center text-slate-500 dark:text-slate-300'>
Selecciona un apartamento para ver su detalle.
</div>
)}
</CardBox>
</div>
</div>
<div className='mt-6 grid gap-6 xl:grid-cols-12'>
<div className='xl:col-span-7'>
<CardBox>
<div className='flex flex-col gap-3 md:flex-row md:items-end md:justify-between'>
<div>
<h3 className='text-2xl font-semibold text-slate-900 dark:text-white'>
Registrar pago y generar recibos
</h3>
<p className='mt-2 text-sm leading-7 text-slate-500 dark:text-slate-300'>
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.
</p>
</div>
<Badge
label='Primera iteración: pagos completos por cuota pendiente'
tone='info'
/>
</div>
<form className='mt-6' onSubmit={handleSubmit}>
<div className='grid gap-6 xl:grid-cols-12'>
<div className='xl:col-span-7'>
<FormField label='Apartamento'>
<select
value={selectedApartmentId}
onChange={(event) => {
setSelectedApartmentId(event.target.value);
setFormError('');
}}
>
{apartments.map((apartment) => (
<option key={apartment.id} value={apartment.id}>
{getApartmentCode(apartment)}
</option>
))}
</select>
</FormField>
<div className='mb-6'>
<div className='mb-2 flex items-center justify-between gap-3'>
<label className='block font-bold'>Cuotas pendientes a cobrar</label>
{selectedCurrency ? (
<Badge label={`Moneda actual: ${selectedCurrency}`} tone='slate' />
) : null}
</div>
<div className='grid gap-3 md:grid-cols-2'>
{selectableCharges.length ? (
selectableCharges.map((charge) => {
const isSelected = selectedChargeIds.includes(charge.id);
return (
<button
key={charge.id}
type='button'
onClick={() => toggleCharge(charge)}
className={`rounded-3xl border p-4 text-left transition-all ${
isSelected
? 'border-indigo-300 bg-indigo-50 shadow-lg shadow-indigo-100 dark:border-pavitra-blue dark:bg-dark-800'
: 'border-slate-200 bg-white hover:border-indigo-200 hover:bg-slate-50 dark:border-dark-700 dark:bg-dark-900 dark:hover:bg-dark-800'
}`}
>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='font-semibold text-slate-900 dark:text-white'>
{formatChargeLabel(charge)}
</p>
<p className='mt-2 inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-300'>
<BaseIcon path={mdiClockOutline} size={16} />
Vence {formatDate(charge.due_date)}
</p>
</div>
<Badge label={isSelected ? 'Seleccionada' : 'Pendiente'} tone={isSelected ? 'success' : 'warning'} />
</div>
<div className='mt-4 flex items-center justify-between gap-3'>
<span className='text-sm text-slate-500 dark:text-slate-300'>
{charge.notes || 'Cuota ordinaria'}
</span>
<span className='text-base font-semibold text-slate-900 dark:text-white'>
{formatMoney(toNumber(charge.amount), charge.currency || 'VES')}
</span>
</div>
</button>
);
})
) : (
<div className='rounded-3xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-700 md:col-span-2'>
El apartamento seleccionado no tiene cuotas pendientes para este flujo.
</div>
)}
</div>
{selectedSummary?.partiallyPaidCharges.length ? (
<div className='mt-3 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-700'>
Hay {selectedSummary.partiallyPaidCharges.length} cuota(s) con abono
parcial. Revisa esos casos manualmente desde{' '}
<Link href='/monthly_charges/monthly_charges-list' className='font-semibold underline'>
Cargos mensuales
</Link>{' '}
para no duplicar montos en esta primera iteración.
</div>
) : null}
</div>
<FormField label='Cuenta, método y fecha de pago'>
<select value={accountId} onChange={(event) => setAccountId(event.target.value)}>
{activeAccounts.length ? (
activeAccounts.map((account) => (
<option key={account.id} value={account.id}>
{account.name} · {account.currency}
</option>
))
) : (
<option value=''>No hay cuentas activas para esta moneda</option>
)}
</select>
<select
value={paymentMethodId}
onChange={(event) => setPaymentMethodId(event.target.value)}
>
{activePaymentMethods.length ? (
activePaymentMethods.map((method) => (
<option key={method.id} value={method.id}>
{method.name}
</option>
))
) : (
<option value=''>No hay métodos activos</option>
)}
</select>
<input type='date' value={paidAt} onChange={(event) => setPaidAt(event.target.value)} />
</FormField>
<FormField label='Referencia de pago'>
<input
type='text'
placeholder='Ej: 009823412 o transferencia 5621'
value={referenceCode}
onChange={(event) => setReferenceCode(event.target.value)}
/>
</FormField>
<FormField label='Observaciones' hasTextareaHeight>
<textarea
placeholder='Notas para el recibo o para el historial del administrador'
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</FormField>
</div>
<div className='xl:col-span-5'>
<div className='rounded-[28px] bg-slate-950 p-5 text-white shadow-xl shadow-slate-900/10'>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-300'>
Resumen del registro
</p>
<div className='mt-4 space-y-4'>
<div className='rounded-3xl border border-white/10 bg-white/5 p-4'>
<p className='text-sm text-slate-300'>Apartamento</p>
<p className='mt-2 text-xl font-semibold'>
{selectedSummary ? getApartmentCode(selectedSummary.apartment) : '—'}
</p>
</div>
<div className='rounded-3xl border border-white/10 bg-white/5 p-4'>
<div className='flex items-center justify-between gap-3'>
<p className='text-sm text-slate-300'>Cuotas seleccionadas</p>
<span className='text-sm font-semibold'>{selectedCharges.length}</span>
</div>
<div className='mt-3 space-y-2'>
{selectedCharges.length ? (
selectedCharges.map((charge) => (
<div key={charge.id} className='flex items-center justify-between gap-3 text-sm'>
<span className='text-slate-200'>{formatChargeLabel(charge)}</span>
<span className='font-semibold'>
{formatMoney(toNumber(charge.amount), charge.currency || 'VES')}
</span>
</div>
))
) : (
<p className='text-sm text-slate-300'>Aún no hay cuotas seleccionadas.</p>
)}
</div>
</div>
<div className='rounded-3xl border border-white/10 bg-white/5 p-4'>
<p className='text-sm text-slate-300'>Monto total</p>
<p className='mt-2 text-2xl font-semibold'>
{selectedCurrency
? formatMoney(totalSelectedAmount, selectedCurrency)
: 'Selecciona cuotas'}
</p>
<p className='mt-2 text-sm text-slate-300'>
{selectedPaymentMethod?.name
? `Método: ${selectedPaymentMethod.name}`
: 'Selecciona un método de pago'}
</p>
</div>
<div className='rounded-3xl border border-white/10 bg-white/5 p-4'>
<p className='text-sm text-slate-300'>Contacto sugerido para el recibo</p>
<p className='mt-2 text-base font-semibold'>
{getPersonDisplayName(selectedSummary?.primaryContact)}
</p>
<p className='mt-1 text-sm text-slate-300'>
{selectedSummary?.primaryContact?.email ||
selectedSummary?.primaryContact?.whatsapp ||
selectedSummary?.primaryContact?.phone ||
'Agrega email o WhatsApp en el módulo Personas'}
</p>
</div>
</div>
<BaseButton
type='submit'
label={isSubmitting ? 'Registrando...' : 'Registrar pago y crear recibos'}
color='info'
className='mt-5 w-full justify-center'
disabled={
isSubmitting ||
!selectedCharges.length ||
!accountId ||
!paymentMethodId ||
!canRegisterPayments
}
/>
</div>
</div>
</div>
</form>
</CardBox>
</div>
<div className='xl:col-span-5 space-y-6'>
<CardBox>
<div className='flex items-center justify-between gap-3'>
<div>
<h3 className='text-2xl font-semibold text-slate-900 dark:text-white'>
Caja y bancos
</h3>
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
Control rápido de disponibilidad por tipo de cuenta y moneda.
</p>
</div>
<Link
href='/accounts/accounts-list'
className='text-sm font-semibold text-indigo-600 transition hover:text-indigo-700 dark:text-pavitra-blue'
>
Ver cuentas
</Link>
</div>
<div className='mt-6 grid gap-3 sm:grid-cols-2'>
<div className='rounded-3xl border border-slate-200 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800'>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-300'>
Banco
</p>
<div className='mt-3'>
<DualCurrencyStack totals={bankAndCashBalances.bank} showZero large />
</div>
</div>
<div className='rounded-3xl border border-slate-200 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800'>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-300'>
Efectivo
</p>
<div className='mt-3'>
<DualCurrencyStack totals={bankAndCashBalances.cash} showZero large />
</div>
</div>
</div>
<div className='mt-6'>
<div className='flex items-center justify-between gap-3'>
<h4 className='text-lg font-semibold text-slate-900 dark:text-white'>
Gastos recientes
</h4>
<Link
href='/expenses/expenses-list'
className='text-sm font-semibold text-indigo-600 transition hover:text-indigo-700 dark:text-pavitra-blue'
>
Abrir gastos
</Link>
</div>
<div className='mt-3 space-y-2'>
{recentExpenses.length ? (
recentExpenses.map((expense) => (
<div
key={expense.id}
className='flex items-start justify-between gap-3 rounded-2xl border border-slate-200 bg-white p-3 dark:border-dark-700 dark:bg-dark-800'
>
<div>
<p className='font-semibold text-slate-900 dark:text-white'>
{expense.concept || expense.vendor_name || 'Gasto sin concepto'}
</p>
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
{expense.account?.name || 'Sin cuenta'} · {formatDate(expense.expense_date)}
</p>
</div>
<span className='text-sm font-semibold text-slate-900 dark:text-white'>
{formatMoney(toNumber(expense.amount), expense.currency || 'VES')}
</span>
</div>
))
) : (
<p className='text-sm text-slate-500 dark:text-slate-300'>
Todavía no hay gastos recientes para mostrar.
</p>
)}
</div>
</div>
</CardBox>
<CardBox>
<div className='flex items-center justify-between gap-3'>
<div>
<h3 className='text-2xl font-semibold text-slate-900 dark:text-white'>
Recibos recientes
</h3>
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
Los recibos se generan al cobrar y luego pueden revisarse o enviarse desde la
administración.
</p>
</div>
<Link
href='/receipts/receipts-list'
className='text-sm font-semibold text-indigo-600 transition hover:text-indigo-700 dark:text-pavitra-blue'
>
Ver todos
</Link>
</div>
<div className='mt-6 space-y-3'>
{recentReceipts.length ? (
recentReceipts.map((receipt) => (
<Link
key={receipt.id}
href={`/receipts/${receipt.id}`}
className='flex items-start justify-between gap-3 rounded-2xl border border-slate-200 bg-white p-4 transition hover:border-indigo-200 hover:bg-slate-50 dark:border-dark-700 dark:bg-dark-800 dark:hover:bg-dark-700'
>
<div>
<p className='font-semibold text-slate-900 dark:text-white'>
{receipt.receipt_number || 'Recibo sin numeración'}
</p>
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
{getApartmentCode(receipt.apartment)} · {formatDate(receipt.issued_at)}
</p>
</div>
<Badge
label={receipt.delivery_status || 'sin estado'}
tone={getDeliveryTone(receipt.delivery_status)}
/>
</Link>
))
) : (
<p className='text-sm text-slate-500 dark:text-slate-300'>
No hay recibos recientes todavía.
</p>
)}
</div>
</CardBox>
</div>
</div>
</>
) : null}
</SectionMain>
</>
);
};
CentroCobranza.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission='READ_PAYMENTS'>{page}</LayoutAuthenticated>;
};
export default CentroCobranza;