1894 lines
75 KiB
TypeScript
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;
|