1022 lines
31 KiB
Markdown
1022 lines
31 KiB
Markdown
# Payment System Implementation Plan
|
|
|
|
## File Structure Overview
|
|
|
|
```
|
|
src/
|
|
├── app/
|
|
│ ├── api/
|
|
│ │ └── payments/
|
|
│ │ ├── route.js # GET all, POST create
|
|
│ │ ├── [id]/
|
|
│ │ │ └── route.js # GET, PUT, DELETE single payment
|
|
│ │ ├── class/
|
|
│ │ │ └── [classId]/
|
|
│ │ │ └── route.js # GET payments by class
|
|
│ │ └── user/
|
|
│ │ └── [userId]/
|
|
│ │ └── route.js # GET payments by user
|
|
│ │
|
|
│ ├── (protected)/
|
|
│ │ ├── admin/
|
|
│ │ │ └── dashboard/
|
|
│ │ │ └── payments/
|
|
│ │ │ ├── page.jsx # Main dashboard
|
|
│ │ │ ├── components/
|
|
│ │ │ │ ├── PaymentsTable.jsx
|
|
│ │ │ │ ├── PaymentDetail.jsx
|
|
│ │ │ │ ├── PaymentStats.jsx
|
|
│ │ │ │ └── VerifyPaymentButton.jsx
|
|
│ │ │
|
|
│ │ ├── dashboard/
|
|
│ │ │ ├── guardian/
|
|
│ │ │ │ ├── payments/
|
|
│ │ │ │ │ ├── page.jsx
|
|
│ │ │ │ │ └── register/
|
|
│ │ │ │ │ ├── page.jsx
|
|
│ │ │ │ │ └── components/
|
|
│ │ │ │ │ ├── PaymentForm.jsx
|
|
│ │ │ │ │ └── ReceiptUpload.jsx
|
|
│ │ │ │ └── class/
|
|
│ │ │ │ └── [id]/
|
|
│ │ │ │ └── payments/
|
|
│ │ │ │ └── page.jsx
|
|
│ │ │ │
|
|
│ │ │ └── student/
|
|
│ │ │ └── payments/
|
|
│ │ │ ├── page.jsx
|
|
│ │ │ └── register/
|
|
│ │ │ ├── page.jsx
|
|
│ │ │ └── components/
|
|
│ │ │ ├── PaymentForm.jsx
|
|
│ │ │ └── ReceiptUpload.jsx
|
|
│ │ │
|
|
│ │ └── components/
|
|
│ │ └── shared/
|
|
│ │ └── PaymentStatusBadge.jsx # Reusable badge component
|
|
│ │
|
|
│ └── (protected)/dashboard/guardian/class/[id]/
|
|
│ └── page.jsx # Update to show payment status
|
|
│
|
|
├── models/
|
|
│ └── Payment.js # New payment model
|
|
│
|
|
└── lib/
|
|
└── utils/
|
|
└── payments.js # Payment helper functions
|
|
```
|
|
|
|
## Detailed Implementation Steps
|
|
|
|
### Step 1: Create Payment Model
|
|
|
|
**File**: `src/app/models/Payment.js`
|
|
|
|
```javascript
|
|
import mongoose from "mongoose";
|
|
import connectDB from "@/app/config/mongodb";
|
|
|
|
const PaymentSchema = new mongoose.Schema({
|
|
classId: {
|
|
type: mongoose.Schema.Types.ObjectId,
|
|
ref: "Class",
|
|
required: [true, "Class is required"],
|
|
index: true,
|
|
},
|
|
userId: {
|
|
type: mongoose.Schema.Types.ObjectId,
|
|
ref: "User",
|
|
required: [true, "User is required"],
|
|
index: true,
|
|
},
|
|
amount: {
|
|
type: Number,
|
|
required: [true, "Amount is required"],
|
|
min: [0, "Amount must be positive"],
|
|
},
|
|
paymentDate: {
|
|
type: Date,
|
|
required: [true, "Payment date is required"],
|
|
default: Date.now,
|
|
},
|
|
paymentMethod: {
|
|
type: String,
|
|
enum: ["pix", "bank_transfer", "cash", "credit_card", "debit_card"],
|
|
required: [true, "Payment method is required"],
|
|
},
|
|
status: {
|
|
type: String,
|
|
enum: ["pending", "verified", "rejected"],
|
|
default: "pending",
|
|
index: true,
|
|
},
|
|
receiptUrl: {
|
|
type: String,
|
|
},
|
|
notes: {
|
|
type: String,
|
|
maxlength: 500,
|
|
},
|
|
createdAt: { type: Date, default: Date.now },
|
|
updatedAt: { type: Date, default: Date.now },
|
|
});
|
|
|
|
PaymentSchema.index({ classId: 1, userId: 1 });
|
|
PaymentSchema.index({ status: 1 });
|
|
|
|
PaymentSchema.pre("save", function(next) {
|
|
this.updatedAt = Date.now();
|
|
next();
|
|
});
|
|
|
|
const getPaymentModel = async () => {
|
|
await connectDB();
|
|
if (process.env.NODE_ENV === "development") {
|
|
delete mongoose.connection.models["Payment"];
|
|
}
|
|
return mongoose.models.Payment || mongoose.model("Payment", PaymentSchema);
|
|
};
|
|
|
|
export { PaymentSchema, getPaymentModel };
|
|
```
|
|
|
|
### Step 2: Create Payment API Routes
|
|
|
|
#### 2.1 Main Payments Route
|
|
|
|
**File**: `src/app/api/payments/route.js`
|
|
|
|
```javascript
|
|
import connectDB from "@/app/config/mongodb";
|
|
import { getPaymentModel } from "@/app/models/Payment";
|
|
import { getClassModel } from "@/app/models/Class";
|
|
import { getUserModel } from "@/app/models/User";
|
|
import { NextResponse } from "next/server";
|
|
import { auth } from "@/app/lib/utils/auth";
|
|
|
|
// GET all payments (admin only)
|
|
export async function GET(request) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const user = await getUserModel();
|
|
const currentUser = await user.findOne({ _id: session.user.id });
|
|
|
|
// Only admins can view all payments
|
|
if (!currentUser.roles.includes("admin")) {
|
|
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 403 });
|
|
}
|
|
|
|
await connectDB();
|
|
|
|
try {
|
|
const Payment = await getPaymentModel();
|
|
const payments = await Payment.find({})
|
|
.populate("classId", "classTitle")
|
|
.populate("userId", "fullName")
|
|
.sort({ createdAt: -1 })
|
|
.lean();
|
|
|
|
return NextResponse.json(payments, { status: 200 });
|
|
} catch (error) {
|
|
console.error("Error fetching payments:", error);
|
|
return NextResponse.json(
|
|
{ success: false, message: "Error fetching payments", error: error.message },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// POST create payment (guardian/student only)
|
|
export async function POST(request) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const user = await getUserModel();
|
|
const currentUser = await user.findOne({ _id: session.user.id });
|
|
|
|
// Only guardians and students can create payments
|
|
if (!currentUser.roles.includes("guardian") && !currentUser.roles.includes("student")) {
|
|
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 403 });
|
|
}
|
|
|
|
await connectDB();
|
|
|
|
try {
|
|
const Payment = await getPaymentModel();
|
|
const Class = await getClassModel();
|
|
|
|
const body = await request.json();
|
|
const { classId, amount, paymentDate, paymentMethod, receiptUrl, notes } = body;
|
|
|
|
// Validate class belongs to user's wards (if guardian) or is enrolled (if student)
|
|
const classData = await Class.findById(classId);
|
|
if (!classData) {
|
|
return NextResponse.json(
|
|
{ success: false, message: "Class not found" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Check if user is guardian and class belongs to their ward
|
|
if (currentUser.roles.includes("guardian")) {
|
|
const isWard = currentUser.wardAccounts.some(wardId => wardId.toString() === classId);
|
|
if (!isWard) {
|
|
return NextResponse.json(
|
|
{ success: false, message: "You can only register payments for your wards' classes" },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check if user is student and enrolled in class
|
|
if (currentUser.roles.includes("student")) {
|
|
const isEnrolled = classData.students.some(studentId =>
|
|
studentId.toString() === session.user.id
|
|
);
|
|
if (!isEnrolled) {
|
|
return NextResponse.json(
|
|
{ success: false, message: "You are not enrolled in this class" },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
}
|
|
|
|
const payment = await Payment.create({
|
|
classId,
|
|
userId: session.user.id,
|
|
amount,
|
|
paymentDate: new Date(paymentDate),
|
|
paymentMethod,
|
|
receiptUrl,
|
|
notes,
|
|
});
|
|
|
|
const populatedPayment = await Payment.findById(payment._id)
|
|
.populate("classId", "classTitle")
|
|
.populate("userId", "fullName")
|
|
.lean();
|
|
|
|
return NextResponse.json(
|
|
{ success: true, message: "Payment registered successfully", data: populatedPayment },
|
|
{ status: 201 }
|
|
);
|
|
} catch (error) {
|
|
console.error("Error creating payment:", error);
|
|
return NextResponse.json(
|
|
{ success: false, message: "Error creating payment", error: error.message },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 2.2 Single Payment Route
|
|
|
|
**File**: `src/app/api/payments/[id]/route.js`
|
|
|
|
```javascript
|
|
import connectDB from "@/app/config/mongodb";
|
|
import { getPaymentModel } from "@/app/models/Payment";
|
|
import { getUserModel } from "@/app/models/User";
|
|
import { NextResponse } from "next/server";
|
|
import { auth } from "@/app/lib/utils/auth";
|
|
|
|
// GET single payment (admin only)
|
|
export async function GET(request, { params }) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const user = await getUserModel();
|
|
const currentUser = await user.findOne({ _id: session.user.id });
|
|
|
|
// Only admins can view payment details
|
|
if (!currentUser.roles.includes("admin")) {
|
|
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 403 });
|
|
}
|
|
|
|
await connectDB();
|
|
|
|
try {
|
|
const Payment = await getPaymentModel();
|
|
const payment = await Payment.findById(params.id)
|
|
.populate("classId", "classTitle")
|
|
.populate("userId", "fullName")
|
|
.lean();
|
|
|
|
if (!payment) {
|
|
return NextResponse.json(
|
|
{ success: false, message: "Payment not found" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json(payment, { status: 200 });
|
|
} catch (error) {
|
|
console.error("Error fetching payment:", error);
|
|
return NextResponse.json(
|
|
{ success: false, message: "Error fetching payment", error: error.message },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// PUT update payment status (admin only)
|
|
export async function PUT(request, { params }) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const user = await getUserModel();
|
|
const currentUser = await user.findOne({ _id: session.user.id });
|
|
|
|
// Only admins can update payment status
|
|
if (!currentUser.roles.includes("admin")) {
|
|
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 403 });
|
|
}
|
|
|
|
await connectDB();
|
|
|
|
try {
|
|
const Payment = await getPaymentModel();
|
|
|
|
const body = await request.json();
|
|
const { status, notes } = body;
|
|
|
|
const payment = await Payment.findByIdAndUpdate(
|
|
params.id,
|
|
{ status, notes, updatedAt: Date.now() },
|
|
{ new: true }
|
|
).populate("classId", "classTitle").populate("userId", "fullName").lean();
|
|
|
|
if (!payment) {
|
|
return NextResponse.json(
|
|
{ success: false, message: "Payment not found" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{ success: true, message: "Payment updated successfully", data: payment },
|
|
{ status: 200 }
|
|
);
|
|
} catch (error) {
|
|
console.error("Error updating payment:", error);
|
|
return NextResponse.json(
|
|
{ success: false, message: "Error updating payment", error: error.message },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// DELETE payment (admin only)
|
|
export async function DELETE(request, { params }) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const user = await getUserModel();
|
|
const currentUser = await user.findOne({ _id: session.user.id });
|
|
|
|
// Only admins can delete payments
|
|
if (!currentUser.roles.includes("admin")) {
|
|
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 403 });
|
|
}
|
|
|
|
await connectDB();
|
|
|
|
try {
|
|
const Payment = await getPaymentModel();
|
|
const payment = await Payment.findByIdAndDelete(params.id);
|
|
|
|
if (!payment) {
|
|
return NextResponse.json(
|
|
{ success: false, message: "Payment not found" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{ success: true, message: "Payment deleted successfully" },
|
|
{ status: 200 }
|
|
);
|
|
} catch (error) {
|
|
console.error("Error deleting payment:", error);
|
|
return NextResponse.json(
|
|
{ success: false, message: "Error deleting payment", error: error.message },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 2.3 Payments by Class Route
|
|
|
|
**File**: `src/app/api/payments/class/[classId]/route.js`
|
|
|
|
```javascript
|
|
import connectDB from "@/app/config/mongodb";
|
|
import { getPaymentModel } from "@/app/models/Payment";
|
|
import { getUserModel } from "@/app/models/User";
|
|
import { NextResponse } from "next/server";
|
|
import { auth } from "@/app/lib/utils/auth";
|
|
|
|
// GET payments for a specific class (admin only)
|
|
export async function GET(request, { params }) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const user = await getUserModel();
|
|
const currentUser = await user.findOne({ _id: session.user.id });
|
|
|
|
// Only admins can view class payments
|
|
if (!currentUser.roles.includes("admin")) {
|
|
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 403 });
|
|
}
|
|
|
|
await connectDB();
|
|
|
|
try {
|
|
const Payment = await getPaymentModel();
|
|
const payments = await Payment.find({ classId: params.classId })
|
|
.populate("userId", "fullName")
|
|
.sort({ createdAt: -1 })
|
|
.lean();
|
|
|
|
return NextResponse.json(payments, { status: 200 });
|
|
} catch (error) {
|
|
console.error("Error fetching class payments:", error);
|
|
return NextResponse.json(
|
|
{ success: false, message: "Error fetching payments", error: error.message },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 2.4 Payments by User Route
|
|
|
|
**File**: `src/app/api/payments/user/[userId]/route.js`
|
|
|
|
```javascript
|
|
import connectDB from "@/app/config/mongodb";
|
|
import { getPaymentModel } from "@/app/models/Payment";
|
|
import { getUserModel } from "@/app/models/User";
|
|
import { NextResponse } from "next/server";
|
|
import { auth } from "@/app/lib/utils/auth";
|
|
|
|
// GET payments for a specific user (guardian/student only)
|
|
export async function GET(request, { params }) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const user = await getUserModel();
|
|
const currentUser = await user.findOne({ _id: session.user.id });
|
|
|
|
// Only guardians and students can view their own payments
|
|
if (!currentUser.roles.includes("guardian") && !currentUser.roles.includes("student")) {
|
|
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 403 });
|
|
}
|
|
|
|
await connectDB();
|
|
|
|
try {
|
|
const Payment = await getPaymentModel();
|
|
|
|
// If guardian, show payments for all their wards
|
|
if (currentUser.roles.includes("guardian")) {
|
|
const payments = await Payment.find({ userId: { $in: currentUser.wardAccounts } })
|
|
.populate("classId", "classTitle")
|
|
.sort({ createdAt: -1 })
|
|
.lean();
|
|
return NextResponse.json(payments, { status: 200 });
|
|
}
|
|
|
|
// If student, show only their own payments
|
|
if (currentUser.roles.includes("student")) {
|
|
const payments = await Payment.find({ userId: session.user.id })
|
|
.populate("classId", "classTitle")
|
|
.sort({ createdAt: -1 })
|
|
.lean();
|
|
return NextResponse.json(payments, { status: 200 });
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching user payments:", error);
|
|
return NextResponse.json(
|
|
{ success: false, message: "Error fetching payments", error: error.message },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 3: Create Helper Functions
|
|
|
|
**File**: `src/lib/utils/payments.js`
|
|
|
|
```javascript
|
|
import { getPaymentModel } from "@/app/models/Payment";
|
|
import { getClassModel } from "@/app/models/Class";
|
|
|
|
// Get payment status for a user in a class
|
|
export async function getPaymentStatus(userId, classId) {
|
|
const Payment = await getPaymentModel();
|
|
const payment = await Payment.findOne({ userId, classId }).sort({ createdAt: -1 }).lean();
|
|
|
|
if (!payment) {
|
|
return { status: "not_paid", amount: null };
|
|
}
|
|
|
|
return {
|
|
status: payment.status,
|
|
amount: payment.amount,
|
|
paymentDate: payment.paymentDate,
|
|
paymentMethod: payment.paymentMethod,
|
|
};
|
|
}
|
|
|
|
// Get all payments for a class with user details
|
|
export async function getClassPayments(classId) {
|
|
const Payment = await getPaymentModel();
|
|
const payments = await Payment.find({ classId })
|
|
.populate("userId", "fullName")
|
|
.sort({ createdAt: -1 })
|
|
.lean();
|
|
|
|
return payments;
|
|
}
|
|
|
|
// Get payment statistics
|
|
export async function getPaymentStats() {
|
|
const Payment = await getPaymentModel();
|
|
|
|
const totalPayments = await Payment.countDocuments();
|
|
const verifiedPayments = await Payment.countDocuments({ status: "verified" });
|
|
const pendingPayments = await Payment.countDocuments({ status: "pending" });
|
|
const rejectedPayments = await Payment.countDocuments({ status: "rejected" });
|
|
|
|
return {
|
|
totalPayments,
|
|
verifiedPayments,
|
|
pendingPayments,
|
|
rejectedPayments,
|
|
verificationRate: totalPayments > 0 ? Math.round((verifiedPayments / totalPayments) * 100) : 0,
|
|
};
|
|
}
|
|
```
|
|
|
|
### Step 4: Create Admin Dashboard Components
|
|
|
|
#### 4.1 Payments Table
|
|
|
|
**File**: `src/app/(protected)/admin/dashboard/payments/components/PaymentsTable.jsx`
|
|
|
|
```javascript
|
|
import { useState } from "react";
|
|
import { format } from "date-fns";
|
|
import { ptBR } from "date-fns/locale";
|
|
|
|
export default function PaymentsTable({ payments }) {
|
|
const [filter, setFilter] = useState("all");
|
|
|
|
const filteredPayments = payments.filter(payment => {
|
|
if (filter === "all") return true;
|
|
return payment.status === filter;
|
|
});
|
|
|
|
const getStatusBadge = (status) => {
|
|
const styles = {
|
|
pending: "bg-yellow-100 text-yellow-800",
|
|
verified: "bg-green-100 text-green-800",
|
|
rejected: "bg-red-100 text-red-800",
|
|
};
|
|
|
|
return (
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[status]}`}>
|
|
{status.charAt(0).toUpperCase() + status.slice(1)}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<div className="flex gap-2 mb-4">
|
|
<button
|
|
onClick={() => setFilter("all")}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium ${
|
|
filter === "all" ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-700"
|
|
}`}
|
|
>
|
|
Todos
|
|
</button>
|
|
<button
|
|
onClick={() => setFilter("pending")}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium ${
|
|
filter === "pending" ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-700"
|
|
}`}
|
|
>
|
|
Pendentes
|
|
</button>
|
|
<button
|
|
onClick={() => setFilter("verified")}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium ${
|
|
filter === "verified" ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-700"
|
|
}`}
|
|
>
|
|
Verificados
|
|
</button>
|
|
<button
|
|
onClick={() => setFilter("rejected")}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium ${
|
|
filter === "rejected" ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-700"
|
|
}`}
|
|
>
|
|
Rejeitados
|
|
</button>
|
|
</div>
|
|
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Aluno
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Classe
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Valor
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Data
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Método
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Status
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{filteredPayments.map((payment) => (
|
|
<tr key={payment._id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{payment.userId?.fullName || "N/A"}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{payment.classId?.classTitle || "N/A"}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
R$ {payment.amount.toFixed(2)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{format(new Date(payment.paymentDate), "dd/MM/yyyy", { locale: ptBR })}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{payment.paymentMethod}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
{getStatusBadge(payment.status)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
{filteredPayments.length === 0 && (
|
|
<div className="text-center py-8 text-gray-500">
|
|
Nenhum pagamento encontrado
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### 4.2 Payment Stats
|
|
|
|
**File**: `src/app/(protected)/admin/dashboard/payments/components/PaymentStats.jsx`
|
|
|
|
```javascript
|
|
export default function PaymentStats({ stats }) {
|
|
const statsCards = [
|
|
{
|
|
title: "Total de Pagamentos",
|
|
value: stats.totalPayments,
|
|
color: "blue",
|
|
icon: "💳",
|
|
},
|
|
{
|
|
title: "Verificados",
|
|
value: stats.verifiedPayments,
|
|
color: "green",
|
|
icon: "✅",
|
|
},
|
|
{
|
|
title: "Pendentes",
|
|
value: stats.pendingPayments,
|
|
color: "yellow",
|
|
icon: "⏳",
|
|
},
|
|
{
|
|
title: "Rejeitados",
|
|
value: stats.rejectedPayments,
|
|
color: "red",
|
|
icon: "❌",
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
|
{statsCards.map((card) => (
|
|
<div
|
|
key={card.title}
|
|
className="bg-white rounded-lg shadow p-6 border-l-4 border-blue-500"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500">{card.title}</p>
|
|
<p className="text-2xl font-bold text-gray-900 mt-1">
|
|
{card.value}
|
|
</p>
|
|
</div>
|
|
<span className="text-3xl">{card.icon}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-purple-500">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500">Taxa de Verificação</p>
|
|
<p className="text-2xl font-bold text-gray-900 mt-1">
|
|
{stats.verificationRate}%
|
|
</p>
|
|
</div>
|
|
<span className="text-3xl">📊</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Step 5: Create Guardian Payment Interface
|
|
|
|
#### 5.1 Payment Form
|
|
|
|
**File**: `src/app/(protected)/dashboard/guardian/payments/register/components/PaymentForm.jsx`
|
|
|
|
```javascript
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { format } from "date-fns";
|
|
import { ptBR } from "date-fns/locale";
|
|
|
|
export default function PaymentForm({ classId, className }) {
|
|
const router = useRouter();
|
|
const [formData, setFormData] = useState({
|
|
amount: "",
|
|
paymentDate: new Date().toISOString().split("T")[0],
|
|
paymentMethod: "pix",
|
|
receipt: null,
|
|
notes: "",
|
|
});
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
setLoading(true);
|
|
setError("");
|
|
|
|
try {
|
|
const formDataToSend = new FormData();
|
|
formDataToSend.append("classId", classId);
|
|
formDataToSend.append("amount", parseFloat(formData.amount));
|
|
formDataToSend.append("paymentDate", formData.paymentDate);
|
|
formDataToSend.append("paymentMethod", formData.paymentMethod);
|
|
if (formData.receipt) {
|
|
formDataToSend.append("receipt", formData.receipt);
|
|
}
|
|
if (formData.notes) {
|
|
formDataToSend.append("notes", formData.notes);
|
|
}
|
|
|
|
const response = await fetch("/api/payments", {
|
|
method: "POST",
|
|
body: formDataToSend,
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.message || "Erro ao registrar pagamento");
|
|
}
|
|
|
|
router.push(`/dashboard/guardian/payments?success=true`);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-2xl mx-auto">
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
|
Registrar Pagamento - {className}
|
|
</h2>
|
|
|
|
{error && (
|
|
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Valor do Pagamento (R$)
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
required
|
|
value={formData.amount}
|
|
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="0,00"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Data do Pagamento
|
|
</label>
|
|
<input
|
|
type="date"
|
|
required
|
|
value={formData.paymentDate}
|
|
onChange={(e) => setFormData({ ...formData, paymentDate: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Método de Pagamento
|
|
</label>
|
|
<select
|
|
required
|
|
value={formData.paymentMethod}
|
|
onChange={(e) => setFormData({ ...formData, paymentMethod: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
<option value="pix">PIX</option>
|
|
<option value="bank_transfer">Transferência Bancária</option>
|
|
<option value="cash">Dinheiro</option>
|
|
<option value="credit_card">Cartão de Crédito</option>
|
|
<option value="debit_card">Cartão de Débito</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Comprovante (Opcional)
|
|
</label>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={(e) => setFormData({ ...formData, receipt: e.target.files[0] })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
Formatos aceitos: JPG, PNG, PDF (máx. 5MB)
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Observações (Opcional)
|
|
</label>
|
|
<textarea
|
|
rows={3}
|
|
value={formData.notes}
|
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="Informações adicionais sobre o pagamento..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-4">
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{loading ? "Registrando..." : "Registrar Pagamento"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => router.back()}
|
|
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Step 6: Create Payment Status Badge Component
|
|
|
|
**File**: `src/app/(protected)/components/shared/PaymentStatusBadge.jsx`
|
|
|
|
```javascript
|
|
export default function PaymentStatusBadge({ status }) {
|
|
const styles = {
|
|
pending: "bg-yellow-100 text-yellow-800 border-yellow-300",
|
|
verified: "bg-green-100 text-green-800 border-green-300",
|
|
rejected: "bg-red-100 text-red-800 border-red-300",
|
|
not_paid: "bg-gray-100 text-gray-800 border-gray-300",
|
|
};
|
|
|
|
const labels = {
|
|
pending: "Pendente",
|
|
verified: "Pago",
|
|
rejected: "Rejeitado",
|
|
not_paid: "Não Pago",
|
|
};
|
|
|
|
return (
|
|
<span
|
|
className={`px-2 py-1 rounded-full text-xs font-medium border ${styles[status]}`}
|
|
>
|
|
{labels[status]}
|
|
</span>
|
|
);
|
|
}
|
|
```
|
|
|
|
## Integration Points
|
|
|
|
### Update Guardian Class Detail Page
|
|
|
|
Add payment status indicator to the class detail page:
|
|
|
|
```javascript
|
|
// In src/app/(protected)/dashboard/guardian/class/[id]/page.jsx
|
|
import PaymentStatusBadge from "@/app/(protected)/components/shared/PaymentStatusBadge";
|
|
|
|
// After fetching class data, add:
|
|
const Payment = await getPaymentModel();
|
|
const paymentStatus = await getPaymentStatus(session.user.id, id);
|
|
|
|
// Add to cls object:
|
|
cls.paymentStatus = paymentStatus.status;
|
|
cls.paymentAmount = paymentStatus.amount;
|
|
|
|
// In the JSX:
|
|
<PaymentStatusBadge status={cls.paymentStatus} />
|
|
```
|
|
|
|
## Testing Checklist
|
|
|
|
- [ ] Create payment model and verify database schema
|
|
- [ ] Test payment creation API with guardian/student accounts
|
|
- [ ] Test payment verification/rejection by admin
|
|
- [ ] Test file upload for receipts
|
|
- [ ] Verify role-based access control
|
|
- [ ] Test payment status indicators in UI
|
|
- [ ] Verify payment history display
|
|
- [ ] Test payment filtering in admin dashboard
|
|
- [ ] Verify payment statistics calculation
|
|
- [ ] Test edge cases (invalid amounts, dates, etc.)
|
|
|
|
## Notes
|
|
|
|
- The system uses the existing file upload infrastructure
|
|
- Payment status workflow: pending → verified/rejected
|
|
- Guardians can only register payments for their wards' classes
|
|
- Students can only register payments for their enrolled classes
|
|
- All payment data is encrypted and secure
|
|
- Receipt images are stored in the existing file storage system
|