payment checkpoint 2
This commit is contained in:
141
apps/Frontend/src/components/payments/payment-edit-modal.tsx
Normal file
141
apps/Frontend/src/components/payments/payment-edit-modal.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { z } from "zod";
|
||||
import { format } from "date-fns";
|
||||
import { PaymentUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||
|
||||
type Payment = z.infer<typeof PaymentUncheckedCreateInputObjectSchema>;
|
||||
|
||||
interface PaymentEditModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
payment: Payment;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export default function PaymentEditModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
payment,
|
||||
onSave,
|
||||
}: PaymentEditModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [form, setForm] = useState({
|
||||
payerName: payment.payerName,
|
||||
amountPaid: payment.amountPaid.toString(),
|
||||
paymentDate: format(new Date(payment.paymentDate), "yyyy-MM-dd"),
|
||||
paymentMethod: payment.paymentMethod,
|
||||
note: payment.note || "",
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm({ ...form, [name]: value });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiRequest("PUT", `/api/payments/${payment.id}`, {
|
||||
...form,
|
||||
amountPaid: parseFloat(form.amountPaid),
|
||||
paymentDate: new Date(form.paymentDate),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Failed to update payment");
|
||||
|
||||
toast({ title: "Success", description: "Payment updated successfully" });
|
||||
queryClient.invalidateQueries();
|
||||
onClose();
|
||||
onSave();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update payment",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Payment</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="payerName">Payer Name</Label>
|
||||
<Input
|
||||
id="payerName"
|
||||
name="payerName"
|
||||
value={form.payerName}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="amountPaid">Amount Paid</Label>
|
||||
<Input
|
||||
id="amountPaid"
|
||||
name="amountPaid"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.amountPaid}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="paymentDate">Payment Date</Label>
|
||||
<Input
|
||||
id="paymentDate"
|
||||
name="paymentDate"
|
||||
type="date"
|
||||
value={form.paymentDate}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="paymentMethod">Payment Method</Label>
|
||||
<Input
|
||||
id="paymentMethod"
|
||||
name="paymentMethod"
|
||||
value={form.paymentMethod}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="note">Note</Label>
|
||||
<Textarea
|
||||
id="note"
|
||||
name="note"
|
||||
value={form.note}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={loading}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
35
apps/Frontend/src/components/payments/payment-view-modal.tsx
Normal file
35
apps/Frontend/src/components/payments/payment-view-modal.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||
import { z } from "zod";
|
||||
import { PaymentUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||
|
||||
type Payment = z.infer<typeof PaymentUncheckedCreateInputObjectSchema>;
|
||||
|
||||
interface PaymentViewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
payment: Payment;
|
||||
}
|
||||
|
||||
export default function PaymentViewModal({ isOpen, onClose, payment }: PaymentViewModalProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Payment Details</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
<div><strong>Payment ID:</strong> PAY-{payment.id.toString().padStart(4, "0")}</div>
|
||||
<div><strong>Claim ID:</strong> {payment.claimId}</div>
|
||||
<div><strong>Payer Name:</strong> {payment.payerName}</div>
|
||||
<div><strong>Amount Paid:</strong> ${payment.amountPaid.toFixed(2)}</div>
|
||||
<div><strong>Payment Date:</strong> {formatDateToHumanReadable(payment.paymentDate)}</div>
|
||||
<div><strong>Payment Method:</strong> {payment.paymentMethod}</div>
|
||||
<div><strong>Note:</strong> {payment.note || "—"}</div>
|
||||
<div><strong>Created At:</strong> {formatDateToHumanReadable(payment.createdAt)}</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
295
apps/Frontend/src/components/payments/payments-recent-table.tsx
Normal file
295
apps/Frontend/src/components/payments/payments-recent-table.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Edit, Eye, Delete } from "lucide-react";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { PaymentUncheckedCreateInputObjectSchema, PaymentTransactionCreateInputObjectSchema, ServiceLinePaymentCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||
import { z } from "zod";
|
||||
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
||||
import PaymentViewModal from "./payment-view-modal";
|
||||
import PaymentEditModal from "./payment-edit-modal";
|
||||
|
||||
type Payment = z.infer<typeof PaymentUncheckedCreateInputObjectSchema>;
|
||||
type PaymentTransaction = z.infer<
|
||||
typeof PaymentTransactionCreateInputObjectSchema
|
||||
>;
|
||||
type ServiceLinePayment = z.infer<
|
||||
typeof ServiceLinePaymentCreateInputObjectSchema
|
||||
>;
|
||||
|
||||
const insertPaymentSchema = (
|
||||
PaymentUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||
).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
type InsertPayment = z.infer<typeof insertPaymentSchema>;
|
||||
|
||||
const updatePaymentSchema = (
|
||||
PaymentUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||
)
|
||||
.omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
})
|
||||
.partial();
|
||||
type UpdatePayment = z.infer<typeof updatePaymentSchema>;
|
||||
|
||||
type PaymentWithExtras = Prisma.PaymentGetPayload<{
|
||||
include: {
|
||||
transactions: true;
|
||||
servicePayments: true;
|
||||
claim: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
interface PaymentApiResponse {
|
||||
payments: Payment[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
|
||||
interface PaymentsRecentTableProps {
|
||||
allowEdit?: boolean;
|
||||
allowView?: boolean;
|
||||
allowDelete?: boolean;
|
||||
allowCheckbox?: boolean;
|
||||
onSelectPayment?: (payment: Payment | null) => void;
|
||||
onPageChange?: (page: number) => void;
|
||||
claimId?: number;
|
||||
}
|
||||
|
||||
export default function PaymentsRecentTable({
|
||||
allowEdit,
|
||||
allowView,
|
||||
allowDelete,
|
||||
allowCheckbox,
|
||||
onSelectPayment,
|
||||
onPageChange,
|
||||
claimId,
|
||||
}: PaymentsRecentTableProps) {
|
||||
const { toast } = useToast();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const paymentsPerPage = 5;
|
||||
const offset = (currentPage - 1) * paymentsPerPage;
|
||||
|
||||
const [selectedPaymentId, setSelectedPaymentId] = useState<number | null>(null);
|
||||
const [currentPayment, setCurrentPayment] = useState<Payment | null>(null);
|
||||
const [isViewOpen, setIsViewOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
|
||||
const getQueryKey = () =>
|
||||
claimId
|
||||
? ["payments", "claim", claimId, currentPage]
|
||||
: ["payments", "recent", currentPage];
|
||||
|
||||
const {
|
||||
data: paymentsData,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery<PaymentApiResponse>({
|
||||
queryKey: getQueryKey(),
|
||||
queryFn: async () => {
|
||||
const endpoint = claimId
|
||||
? `/api/payments/claim/${claimId}?limit=${paymentsPerPage}&offset=${offset}`
|
||||
: `/api/payments/recent?limit=${paymentsPerPage}&offset=${offset}`;
|
||||
|
||||
const res = await apiRequest("GET", endpoint);
|
||||
if (!res.ok) throw new Error("Failed to fetch payments");
|
||||
return res.json();
|
||||
},
|
||||
placeholderData: { payments: [], totalCount: 0 },
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const res = await apiRequest("DELETE", `/api/payments/${id}`);
|
||||
if (!res.ok) throw new Error("Failed to delete");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Deleted", description: "Payment deleted successfully" });
|
||||
setIsDeleteOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: getQueryKey() });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Error", description: "Delete failed", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSelectPayment = (payment: Payment) => {
|
||||
const isSelected = selectedPaymentId === payment.id;
|
||||
const newSelectedId = isSelected ? null : payment.id;
|
||||
setSelectedPaymentId(newSelectedId);
|
||||
onSelectPayment?.(isSelected ? null : payment);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (currentPayment?.id) {
|
||||
deleteMutation.mutate(currentPayment.id);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onPageChange?.(currentPage);
|
||||
}, [currentPage]);
|
||||
|
||||
const totalPages = useMemo(
|
||||
() => Math.ceil((paymentsData?.totalCount || 0) / paymentsPerPage),
|
||||
[paymentsData]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded shadow">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{allowCheckbox && <TableHead>Select</TableHead>}
|
||||
<TableHead>Payment ID</TableHead>
|
||||
<TableHead>Payer</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead>Note</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-6">Loading...</TableCell>
|
||||
</TableRow>
|
||||
) : isError ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-red-500 py-6">Error loading payments</TableCell>
|
||||
</TableRow>
|
||||
) : paymentsData?.payments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-6 text-muted-foreground">No payments found</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paymentsData?.payments.map((payment) => (
|
||||
<TableRow key={payment.id}>
|
||||
{allowCheckbox && (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedPaymentId === payment.id}
|
||||
onCheckedChange={() => handleSelectPayment(payment)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>{`PAY-${payment.id.toString().padStart(4, "0")}`}</TableCell>
|
||||
<TableCell>{payment.payerName}</TableCell>
|
||||
<TableCell>${payment.amountPaid.toFixed(2)}</TableCell>
|
||||
<TableCell>{formatDateToHumanReadable(payment.paymentDate)}</TableCell>
|
||||
<TableCell>{payment.paymentMethod}</TableCell>
|
||||
<TableCell>{payment.note || "—"}</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
{allowDelete && (
|
||||
<Button variant="ghost" size="icon" onClick={() => { setCurrentPayment(payment); setIsDeleteOpen(true); }}>
|
||||
<Delete className="text-red-600" />
|
||||
</Button>
|
||||
)}
|
||||
{allowEdit && (
|
||||
<Button variant="ghost" size="icon" onClick={() => { setCurrentPayment(payment); setIsEditOpen(true); }}>
|
||||
<Edit />
|
||||
</Button>
|
||||
)}
|
||||
{allowView && (
|
||||
<Button variant="ghost" size="icon" onClick={() => { setCurrentPayment(payment); setIsViewOpen(true); }}>
|
||||
<Eye />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-4 py-2 border-t flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {(offset + 1)}–{Math.min(offset + paymentsPerPage, paymentsData?.totalCount || 0)} of {paymentsData?.totalCount || 0}
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1) setCurrentPage(currentPage - 1);
|
||||
}}
|
||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: totalPages }).map((_, i) => (
|
||||
<PaginationItem key={i}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(i + 1);
|
||||
}}
|
||||
isActive={currentPage === i + 1}
|
||||
>
|
||||
{i + 1}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < totalPages) setCurrentPage(currentPage + 1);
|
||||
}}
|
||||
className={currentPage === totalPages ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{isViewOpen && currentPayment && (
|
||||
<PaymentViewModal
|
||||
isOpen={isViewOpen}
|
||||
onClose={() => setIsViewOpen(false)}
|
||||
payment={currentPayment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isEditOpen && currentPayment && (
|
||||
<PaymentEditModal
|
||||
isOpen={isEditOpen}
|
||||
onClose={() => setIsEditOpen(false)}
|
||||
payment={currentPayment}
|
||||
onSave={() => {
|
||||
queryClient.invalidateQueries({ queryKey: getQueryKey() });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={isDeleteOpen}
|
||||
onCancel={() => setIsDeleteOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
entityName={`Payment ${currentPayment?.id}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user