- Replace misleading 'sent' status with 'pending' (no email capability) - Show assessment group name instead of raw 'regular_assessment' type - Add owner last name to invoice table - Fix payment creation Internal Server Error (PostgreSQL $2 type cast) - Add edit/delete capability for payment records with invoice recalc Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Title, Table, Group, Button, Stack, Text, Badge, Modal,
|
|
NumberInput, Select, TextInput, Loader, Center, ActionIcon, Tooltip,
|
|
} from '@mantine/core';
|
|
import { DateInput } from '@mantine/dates';
|
|
import { useForm } from '@mantine/form';
|
|
import { useDisclosure } from '@mantine/hooks';
|
|
import { notifications } from '@mantine/notifications';
|
|
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import api from '../../services/api';
|
|
import { useIsReadOnly } from '../../stores/authStore';
|
|
|
|
interface Payment {
|
|
id: string; unit_id: string; unit_number: string; invoice_id: string;
|
|
invoice_number: string; payment_date: string; amount: string;
|
|
payment_method: string; reference_number: string; status: string; notes: string;
|
|
}
|
|
|
|
export function PaymentsPage() {
|
|
const [opened, { open, close }] = useDisclosure(false);
|
|
const [editing, setEditing] = useState<Payment | null>(null);
|
|
const [deleteConfirm, setDeleteConfirm] = useState<Payment | null>(null);
|
|
const queryClient = useQueryClient();
|
|
const isReadOnly = useIsReadOnly();
|
|
|
|
const { data: payments = [], isLoading } = useQuery<Payment[]>({
|
|
queryKey: ['payments'],
|
|
queryFn: async () => { const { data } = await api.get('/payments'); return data; },
|
|
});
|
|
|
|
const { data: invoices = [] } = useQuery<any[]>({
|
|
queryKey: ['invoices-unpaid'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get('/invoices');
|
|
return data.filter((i: any) => i.status !== 'paid' && i.status !== 'void');
|
|
},
|
|
});
|
|
|
|
const form = useForm({
|
|
initialValues: {
|
|
invoice_id: '', amount: 0, payment_method: 'check',
|
|
reference_number: '', payment_date: new Date(), notes: '',
|
|
},
|
|
});
|
|
|
|
const invalidateAll = () => {
|
|
queryClient.invalidateQueries({ queryKey: ['payments'] });
|
|
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
|
queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] });
|
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
|
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
|
|
};
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (values: any) => {
|
|
const inv = invoices.find((i: any) => i.id === values.invoice_id);
|
|
return api.post('/payments', {
|
|
...values,
|
|
unit_id: inv?.unit_id,
|
|
payment_date: values.payment_date.toISOString().split('T')[0],
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
invalidateAll();
|
|
notifications.show({ message: 'Payment recorded', color: 'green' });
|
|
close(); setEditing(null); form.reset();
|
|
},
|
|
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: (values: any) => {
|
|
return api.put(`/payments/${editing!.id}`, {
|
|
payment_date: values.payment_date.toISOString().split('T')[0],
|
|
amount: values.amount,
|
|
payment_method: values.payment_method,
|
|
reference_number: values.reference_number,
|
|
notes: values.notes,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
invalidateAll();
|
|
notifications.show({ message: 'Payment updated', color: 'green' });
|
|
close(); setEditing(null); form.reset();
|
|
},
|
|
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: string) => api.delete(`/payments/${id}`),
|
|
onSuccess: () => {
|
|
invalidateAll();
|
|
notifications.show({ message: 'Payment deleted', color: 'orange' });
|
|
setDeleteConfirm(null);
|
|
close(); setEditing(null); form.reset();
|
|
},
|
|
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
|
|
});
|
|
|
|
const handleEdit = (payment: Payment) => {
|
|
setEditing(payment);
|
|
form.setValues({
|
|
invoice_id: payment.invoice_id || '',
|
|
amount: parseFloat(payment.amount || '0'),
|
|
payment_method: payment.payment_method || 'check',
|
|
reference_number: payment.reference_number || '',
|
|
payment_date: new Date(payment.payment_date),
|
|
notes: payment.notes || '',
|
|
});
|
|
open();
|
|
};
|
|
|
|
const handleNew = () => {
|
|
setEditing(null);
|
|
form.reset();
|
|
open();
|
|
};
|
|
|
|
const handleSubmit = (values: any) => {
|
|
if (editing) {
|
|
updateMutation.mutate(values);
|
|
} else {
|
|
createMutation.mutate(values);
|
|
}
|
|
};
|
|
|
|
const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
|
|
|
const formatPeriod = (inv: any) => {
|
|
if (inv.period_start && inv.period_end) {
|
|
const start = new Date(inv.period_start).toLocaleDateString(undefined, { month: 'short' });
|
|
const end = new Date(inv.period_end).toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
|
|
return inv.period_start === inv.period_end ? start : `${start}-${end}`;
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const invoiceOptions = invoices.map((i: any) => {
|
|
const period = formatPeriod(i);
|
|
const periodStr = period ? ` - ${period}` : '';
|
|
return {
|
|
value: i.id,
|
|
label: `${i.invoice_number} - ${i.unit_number || 'Unit'}${periodStr} - Balance: $${parseFloat(i.balance_due || i.amount).toFixed(2)}`,
|
|
};
|
|
});
|
|
|
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
|
|
|
return (
|
|
<Stack>
|
|
<Group justify="space-between">
|
|
<Title order={2}>Payments</Title>
|
|
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Record Payment</Button>}
|
|
</Group>
|
|
<Table striped highlightOnHover>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Date</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Invoice</Table.Th>
|
|
<Table.Th ta="right">Amount</Table.Th><Table.Th>Method</Table.Th>
|
|
<Table.Th>Reference</Table.Th><Table.Th>Status</Table.Th>
|
|
{!isReadOnly && <Table.Th></Table.Th>}
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{payments.map((p) => (
|
|
<Table.Tr key={p.id}>
|
|
<Table.Td>{new Date(p.payment_date).toLocaleDateString()}</Table.Td>
|
|
<Table.Td>{p.unit_number}</Table.Td>
|
|
<Table.Td>{p.invoice_number}</Table.Td>
|
|
<Table.Td ta="right" ff="monospace" fw={500}>{fmt(p.amount)}</Table.Td>
|
|
<Table.Td><Badge size="sm" variant="light">{p.payment_method}</Badge></Table.Td>
|
|
<Table.Td>{p.reference_number}</Table.Td>
|
|
<Table.Td><Badge color={p.status === 'completed' ? 'green' : 'yellow'} size="sm">{p.status}</Badge></Table.Td>
|
|
{!isReadOnly && (
|
|
<Table.Td>
|
|
<Tooltip label="Edit payment">
|
|
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
|
<IconEdit size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Table.Td>
|
|
)}
|
|
</Table.Tr>
|
|
))}
|
|
{payments.length === 0 && (
|
|
<Table.Tr><Table.Td colSpan={isReadOnly ? 7 : 8}><Text ta="center" c="dimmed" py="lg">No payments recorded yet</Text></Table.Td></Table.Tr>
|
|
)}
|
|
</Table.Tbody>
|
|
</Table>
|
|
|
|
{/* Create / Edit Payment Modal */}
|
|
<Modal opened={opened} onClose={() => { close(); setEditing(null); form.reset(); }} title={editing ? 'Edit Payment' : 'Record Payment'}>
|
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
<Stack>
|
|
{!editing && (
|
|
<Select label="Invoice" required data={invoiceOptions} searchable
|
|
{...form.getInputProps('invoice_id')} />
|
|
)}
|
|
{editing && (
|
|
<TextInput label="Invoice" value={editing.invoice_number || 'N/A'} disabled />
|
|
)}
|
|
<DateInput label="Payment Date" required {...form.getInputProps('payment_date')} />
|
|
<NumberInput label="Amount" required prefix="$" decimalScale={2} min={0.01}
|
|
{...form.getInputProps('amount')} />
|
|
<Select label="Payment Method" data={[
|
|
{ value: 'check', label: 'Check' }, { value: 'ach', label: 'ACH' },
|
|
{ value: 'credit_card', label: 'Credit Card' }, { value: 'cash', label: 'Cash' },
|
|
{ value: 'wire', label: 'Wire' }, { value: 'other', label: 'Other' },
|
|
]} {...form.getInputProps('payment_method')} />
|
|
<TextInput label="Reference Number" placeholder="Check # or transaction ID"
|
|
{...form.getInputProps('reference_number')} />
|
|
<TextInput label="Notes" placeholder="Optional notes"
|
|
{...form.getInputProps('notes')} />
|
|
|
|
<Group justify="space-between">
|
|
{editing ? (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
color="red"
|
|
leftSection={<IconTrash size={16} />}
|
|
onClick={() => setDeleteConfirm(editing)}
|
|
>
|
|
Delete Payment
|
|
</Button>
|
|
<Button type="submit" loading={updateMutation.isPending}>
|
|
Update Payment
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button type="submit" fullWidth loading={createMutation.isPending}>Record Payment</Button>
|
|
)}
|
|
</Group>
|
|
</Stack>
|
|
</form>
|
|
</Modal>
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
<Modal
|
|
opened={!!deleteConfirm}
|
|
onClose={() => setDeleteConfirm(null)}
|
|
title="Delete Payment"
|
|
size="sm"
|
|
>
|
|
<Stack>
|
|
<Text size="sm">
|
|
Are you sure you want to delete this payment of{' '}
|
|
<Text span fw={700}>{deleteConfirm ? fmt(deleteConfirm.amount) : ''}</Text>{' '}
|
|
for unit {deleteConfirm?.unit_number}?
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
This will also remove the associated journal entry and recalculate the invoice balance.
|
|
</Text>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
|
|
<Button
|
|
color="red"
|
|
loading={deleteMutation.isPending}
|
|
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
|
|
>
|
|
Delete Payment
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
</Stack>
|
|
);
|
|
}
|