fix: resolve 5 invoice/payment issues from user feedback
- 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>
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Button, Stack, Text, Badge, Modal,
|
||||
NumberInput, Select, TextInput, Loader, Center,
|
||||
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 } from '@tabler/icons-react';
|
||||
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';
|
||||
@@ -15,11 +15,13 @@ 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;
|
||||
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();
|
||||
|
||||
@@ -39,10 +41,18 @@ export function PaymentsPage() {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
invoice_id: '', amount: 0, payment_method: 'check',
|
||||
reference_number: '', payment_date: new Date(),
|
||||
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);
|
||||
@@ -53,16 +63,69 @@ export function PaymentsPage() {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['payments'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
invalidateAll();
|
||||
notifications.show({ message: 'Payment recorded', color: 'green' });
|
||||
close(); form.reset();
|
||||
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) => {
|
||||
@@ -89,7 +152,7 @@ export function PaymentsPage() {
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Payments</Title>
|
||||
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={open}>Record Payment</Button>}
|
||||
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Record Payment</Button>}
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
@@ -97,6 +160,7 @@ export function PaymentsPage() {
|
||||
<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>
|
||||
@@ -109,18 +173,34 @@ export function PaymentsPage() {
|
||||
<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={7}><Text ta="center" c="dimmed" py="lg">No payments recorded yet</Text></Table.Td></Table.Tr>
|
||||
<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>
|
||||
<Modal opened={opened} onClose={close} title="Record Payment">
|
||||
<form onSubmit={form.onSubmit((v) => createMutation.mutate(v))}>
|
||||
|
||||
{/* 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>
|
||||
<Select label="Invoice" required data={invoiceOptions} searchable
|
||||
{...form.getInputProps('invoice_id')} />
|
||||
{!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')} />
|
||||
@@ -131,10 +211,60 @@ export function PaymentsPage() {
|
||||
]} {...form.getInputProps('payment_method')} />
|
||||
<TextInput label="Reference Number" placeholder="Check # or transaction ID"
|
||||
{...form.getInputProps('reference_number')} />
|
||||
<Button type="submit" loading={createMutation.isPending}>Record Payment</Button>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user