create-steve-rogers 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/SFMS/.env +9 -0
- package/apps/SFMS/README.md +0 -0
- package/apps/SFMS/backend/.env +9 -0
- package/apps/SFMS/backend/.env.example +9 -0
- package/apps/SFMS/backend/package-lock.json +1580 -0
- package/apps/SFMS/backend/package.json +23 -0
- package/apps/SFMS/backend/src/config/database.js +7 -0
- package/apps/SFMS/backend/src/config/env.js +35 -0
- package/apps/SFMS/backend/src/middleware/authMiddleware.js +32 -0
- package/apps/SFMS/backend/src/models/Payment.js +12 -0
- package/apps/SFMS/backend/src/models/Student.js +12 -0
- package/apps/SFMS/backend/src/models/User.js +13 -0
- package/apps/SFMS/backend/src/routes/authRoutes.js +93 -0
- package/apps/SFMS/backend/src/routes/paymentRoutes.js +117 -0
- package/apps/SFMS/backend/src/routes/reportRoutes.js +59 -0
- package/apps/SFMS/backend/src/routes/studentRoutes.js +79 -0
- package/apps/SFMS/backend/src/server.js +34 -0
- package/apps/SFMS/frontend/.env.example +8 -0
- package/apps/SFMS/frontend/dist/assets/index-B08X8imN.css +1 -0
- package/apps/SFMS/frontend/dist/assets/index-DVO0_wcb.js +67 -0
- package/apps/SFMS/frontend/dist/favicon.svg +4 -0
- package/apps/SFMS/frontend/dist/index.html +20 -0
- package/apps/SFMS/frontend/index.html +19 -0
- package/apps/SFMS/frontend/package-lock.json +2667 -0
- package/apps/SFMS/frontend/package.json +23 -0
- package/apps/SFMS/frontend/postcss.config.js +6 -0
- package/apps/SFMS/frontend/public/favicon.svg +4 -0
- package/apps/SFMS/frontend/src/App.jsx +41 -0
- package/apps/SFMS/frontend/src/api/apiClient.js +41 -0
- package/apps/SFMS/frontend/src/components/AppLayout.jsx +60 -0
- package/apps/SFMS/frontend/src/context/AuthContext.jsx +79 -0
- package/apps/SFMS/frontend/src/index.css +229 -0
- package/apps/SFMS/frontend/src/main.jsx +16 -0
- package/apps/SFMS/frontend/src/pages/DashboardPage.jsx +82 -0
- package/apps/SFMS/frontend/src/pages/LoginPage.jsx +142 -0
- package/apps/SFMS/frontend/src/pages/PaymentsPage.jsx +269 -0
- package/apps/SFMS/frontend/src/pages/ReportsPage.jsx +114 -0
- package/apps/SFMS/frontend/src/pages/StudentsPage.jsx +257 -0
- package/apps/SFMS/frontend/tailwind.config.js +21 -0
- package/apps/SFMS/frontend/vite.config.js +35 -0
- package/apps/config.js +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Navigate, useNavigate, useLocation } from 'react-router-dom';
|
|
3
|
+
import { useAuth } from '../context/AuthContext.jsx';
|
|
4
|
+
|
|
5
|
+
export function LoginPage() {
|
|
6
|
+
const { isAuthenticated, login, register } = useAuth();
|
|
7
|
+
const navigate = useNavigate();
|
|
8
|
+
const location = useLocation();
|
|
9
|
+
const from = location.state?.from;
|
|
10
|
+
const returnPath = from?.pathname ? `${from.pathname}${from.search || ''}` : '/';
|
|
11
|
+
|
|
12
|
+
const [mode, setMode] = useState('login');
|
|
13
|
+
const [email, setEmail] = useState('');
|
|
14
|
+
const [password, setPassword] = useState('');
|
|
15
|
+
const [name, setName] = useState('');
|
|
16
|
+
const [fieldErrors, setFieldErrors] = useState({});
|
|
17
|
+
const [formError, setFormError] = useState('');
|
|
18
|
+
const [submitting, setSubmitting] = useState(false);
|
|
19
|
+
|
|
20
|
+
if (isAuthenticated) return <Navigate to={returnPath} replace />;
|
|
21
|
+
|
|
22
|
+
function validate() {
|
|
23
|
+
const err = {};
|
|
24
|
+
if (!email.trim()) err.email = 'Email is required';
|
|
25
|
+
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) err.email = 'Invalid email';
|
|
26
|
+
if (!password || password.length < 6) err.password = 'At least 6 characters';
|
|
27
|
+
if (mode === 'register' && !name.trim()) err.name = 'Name is required';
|
|
28
|
+
setFieldErrors(err);
|
|
29
|
+
return Object.keys(err).length === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function onSubmit(e) {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
setFormError('');
|
|
35
|
+
if (!validate()) return;
|
|
36
|
+
setSubmitting(true);
|
|
37
|
+
try {
|
|
38
|
+
if (mode === 'login') await login(email.trim(), password);
|
|
39
|
+
else await register(name.trim(), email.trim(), password, 'admin');
|
|
40
|
+
navigate(returnPath, { replace: true });
|
|
41
|
+
} catch (err) {
|
|
42
|
+
setFormError(err.message || 'Something went wrong');
|
|
43
|
+
} finally {
|
|
44
|
+
setSubmitting(false);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="auth-page">
|
|
50
|
+
<div className="auth-card">
|
|
51
|
+
<div className="auth-split">
|
|
52
|
+
<section className="auth-panel">
|
|
53
|
+
<p className="text-sm uppercase tracking-[0.3em] text-slate-500">School fee system</p>
|
|
54
|
+
<h1 className="mt-4 text-4xl font-display font-bold text-slate-900">Access your SFMS dashboard</h1>
|
|
55
|
+
<p className="mt-3 text-slate-600 leading-7">Sign in to track students, payments, and reports in one clean workspace.</p>
|
|
56
|
+
|
|
57
|
+
<div className="auth-switcher mt-8">
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
className={mode === 'login' ? 'active' : ''}
|
|
61
|
+
onClick={() => {
|
|
62
|
+
setMode('login');
|
|
63
|
+
setFieldErrors({});
|
|
64
|
+
setFormError('');
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
Login
|
|
68
|
+
</button>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
className={mode === 'register' ? 'active' : ''}
|
|
72
|
+
onClick={() => {
|
|
73
|
+
setMode('register');
|
|
74
|
+
setFieldErrors({});
|
|
75
|
+
setFormError('');
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
Register
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{formError && (
|
|
83
|
+
<div className="mt-6 rounded-3xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
84
|
+
{formError}
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
|
|
88
|
+
<form onSubmit={onSubmit} className="mt-6 space-y-5">
|
|
89
|
+
{mode === 'register' && (
|
|
90
|
+
<div className="input-group">
|
|
91
|
+
<label className="text-sm font-medium text-slate-700">Full name</label>
|
|
92
|
+
<input
|
|
93
|
+
type="text"
|
|
94
|
+
value={name}
|
|
95
|
+
onChange={(e) => setName(e.target.value)}
|
|
96
|
+
className="input-field"
|
|
97
|
+
autoComplete="name"
|
|
98
|
+
/>
|
|
99
|
+
{fieldErrors.name && <p className="text-xs text-red-600">{fieldErrors.name}</p>}
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
<div className="input-group">
|
|
103
|
+
<label className="text-sm font-medium text-slate-700">Email</label>
|
|
104
|
+
<input
|
|
105
|
+
type="email"
|
|
106
|
+
value={email}
|
|
107
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
108
|
+
className="input-field"
|
|
109
|
+
autoComplete="email"
|
|
110
|
+
/>
|
|
111
|
+
{fieldErrors.email && <p className="text-xs text-red-600">{fieldErrors.email}</p>}
|
|
112
|
+
</div>
|
|
113
|
+
<div className="input-group">
|
|
114
|
+
<label className="text-sm font-medium text-slate-700">Password</label>
|
|
115
|
+
<input
|
|
116
|
+
type="password"
|
|
117
|
+
value={password}
|
|
118
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
119
|
+
className="input-field"
|
|
120
|
+
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
|
121
|
+
/>
|
|
122
|
+
{fieldErrors.password && <p className="text-xs text-red-600">{fieldErrors.password}</p>}
|
|
123
|
+
</div>
|
|
124
|
+
<button type="submit" disabled={submitting} className="btn-primary w-full">
|
|
125
|
+
{submitting ? 'Please wait…' : mode === 'login' ? 'Sign in' : 'Create account'}
|
|
126
|
+
</button>
|
|
127
|
+
</form>
|
|
128
|
+
</section>
|
|
129
|
+
<aside className="auth-side">
|
|
130
|
+
<p className="auth-logo">SFMS</p>
|
|
131
|
+
<p className="auth-copy">
|
|
132
|
+
A modern school fee experience with calm colors, spacious forms, and clear navigation.
|
|
133
|
+
</p>
|
|
134
|
+
<p className="text-sm text-slate-100/80">
|
|
135
|
+
Keep student records clean, track every payment, and build reports without clutter.
|
|
136
|
+
</p>
|
|
137
|
+
</aside>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { apiRequest } from '../api/apiClient.js';
|
|
3
|
+
|
|
4
|
+
const inputClass = 'input-field';
|
|
5
|
+
|
|
6
|
+
export function PaymentsPage() {
|
|
7
|
+
const [list, setList] = useState([]);
|
|
8
|
+
const [students, setStudents] = useState([]);
|
|
9
|
+
const [submitting, setSubmitting] = useState(false);
|
|
10
|
+
const [error, setError] = useState('');
|
|
11
|
+
const [formTarget, setFormTarget] = useState(null);
|
|
12
|
+
|
|
13
|
+
const [studentId, setStudentId] = useState('');
|
|
14
|
+
const [amount, setAmount] = useState('');
|
|
15
|
+
const [paymentDate, setPaymentDate] = useState('');
|
|
16
|
+
const [fieldErrors, setFieldErrors] = useState({});
|
|
17
|
+
|
|
18
|
+
async function load() {
|
|
19
|
+
setError('');
|
|
20
|
+
try {
|
|
21
|
+
const [pay, stud] = await Promise.all([
|
|
22
|
+
apiRequest('/api/payments'),
|
|
23
|
+
apiRequest('/api/students'),
|
|
24
|
+
]);
|
|
25
|
+
setList(pay);
|
|
26
|
+
setStudents(stud);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
setError(e.message);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
load();
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
function validate() {
|
|
37
|
+
const err = {};
|
|
38
|
+
if (!studentId) err.studentId = 'Select a student';
|
|
39
|
+
if (!amount.trim()) {
|
|
40
|
+
err.amount = 'Amount is required';
|
|
41
|
+
} else if (!/^[0-9]+$/.test(amount)) {
|
|
42
|
+
err.amount = 'Amount must contain only digits';
|
|
43
|
+
} else if (Number(amount) < 0) {
|
|
44
|
+
err.amount = 'Amount must be zero or greater';
|
|
45
|
+
}
|
|
46
|
+
if (!paymentDate) err.paymentDate = 'Pick a date';
|
|
47
|
+
setFieldErrors(err);
|
|
48
|
+
return Object.keys(err).length === 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function openNew() {
|
|
52
|
+
setFormTarget('new');
|
|
53
|
+
setStudentId(students[0]?.id || '');
|
|
54
|
+
setAmount('');
|
|
55
|
+
setPaymentDate(new Date().toISOString().slice(0, 10));
|
|
56
|
+
setFieldErrors({});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function openEdit(row) {
|
|
60
|
+
setFormTarget(row);
|
|
61
|
+
setStudentId(row.student_id || '');
|
|
62
|
+
setAmount(String(row.amount ?? ''));
|
|
63
|
+
setPaymentDate(row.payment_date?.slice(0, 10) || '');
|
|
64
|
+
setFieldErrors({});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function closeForm() {
|
|
68
|
+
setFormTarget(null);
|
|
69
|
+
setFieldErrors({});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function save(e) {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
if (!validate()) return;
|
|
75
|
+
setSubmitting(true);
|
|
76
|
+
setError('');
|
|
77
|
+
try {
|
|
78
|
+
const payload = {
|
|
79
|
+
student_id: studentId,
|
|
80
|
+
amount: Number(amount),
|
|
81
|
+
payment_date: paymentDate,
|
|
82
|
+
};
|
|
83
|
+
if (formTarget !== 'new' && formTarget?.id) {
|
|
84
|
+
await apiRequest(`/api/payments/${formTarget.id}`, {
|
|
85
|
+
method: 'PUT',
|
|
86
|
+
body: JSON.stringify(payload),
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
await apiRequest('/api/payments', { method: 'POST', body: JSON.stringify(payload) });
|
|
90
|
+
}
|
|
91
|
+
closeForm();
|
|
92
|
+
await load();
|
|
93
|
+
} catch (err) {
|
|
94
|
+
setError(err.message);
|
|
95
|
+
} finally {
|
|
96
|
+
setSubmitting(false);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function remove(id) {
|
|
101
|
+
if (!window.confirm('Delete this payment record?')) return;
|
|
102
|
+
try {
|
|
103
|
+
await apiRequest(`/api/payments/${id}`, { method: 'DELETE' });
|
|
104
|
+
await load();
|
|
105
|
+
} catch (e) {
|
|
106
|
+
setError(e.message);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div>
|
|
112
|
+
<div className="app-header">
|
|
113
|
+
<p className="text-sm uppercase tracking-[0.3em] text-slate-500">Payments</p>
|
|
114
|
+
<h1 className="page-title">Fee transactions</h1>
|
|
115
|
+
<p className="page-subtitle">Record each payment and keep your financial view tidy.</p>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-6">
|
|
119
|
+
<div className="space-y-1">
|
|
120
|
+
<p className="text-base font-semibold text-slate-900">Payments ledger</p>
|
|
121
|
+
<p className="text-sm text-slate-500">Create new entries or update existing ones quickly.</p>
|
|
122
|
+
</div>
|
|
123
|
+
<button type="button" onClick={openNew} disabled={students.length === 0} className="btn-primary w-full sm:w-auto disabled:opacity-50">
|
|
124
|
+
+ New payment
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{students.length === 0 && (
|
|
129
|
+
<div className="mb-6 rounded-[28px] border border-amber-100 bg-amber-50 px-4 py-4 text-sm text-amber-800">
|
|
130
|
+
Add at least one student before recording payments.
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{error && (
|
|
135
|
+
<div className="mb-6 rounded-[28px] border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
136
|
+
{error}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{formTarget === 'new' && (
|
|
141
|
+
<form onSubmit={save} className="form-card mb-8 max-w-2xl space-y-5">
|
|
142
|
+
<div className="flex items-center justify-between gap-4">
|
|
143
|
+
<h2 className="text-xl font-semibold text-slate-900">New payment</h2>
|
|
144
|
+
<button type="button" onClick={closeForm} className="btn-secondary">
|
|
145
|
+
Close
|
|
146
|
+
</button>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="grid gap-4 md:grid-cols-3">
|
|
149
|
+
<div className="input-group">
|
|
150
|
+
<label className="text-sm font-medium text-slate-700">Student</label>
|
|
151
|
+
<select className={inputClass} value={studentId} onChange={(e) => setStudentId(e.target.value)}>
|
|
152
|
+
{students.map((s) => (
|
|
153
|
+
<option key={s.id} value={s.id}>
|
|
154
|
+
{s.full_name} — {s.class}
|
|
155
|
+
</option>
|
|
156
|
+
))}
|
|
157
|
+
</select>
|
|
158
|
+
{fieldErrors.studentId && <p className="text-xs text-red-600">{fieldErrors.studentId}</p>}
|
|
159
|
+
</div>
|
|
160
|
+
<div className="input-group">
|
|
161
|
+
<label className="text-sm font-medium text-slate-700">Amount</label>
|
|
162
|
+
<input
|
|
163
|
+
type="text"
|
|
164
|
+
inputMode="numeric"
|
|
165
|
+
className={inputClass}
|
|
166
|
+
value={amount}
|
|
167
|
+
onChange={(e) => setAmount(e.target.value.replace(/\D/g, ''))}
|
|
168
|
+
placeholder="Numbers only"
|
|
169
|
+
/>
|
|
170
|
+
{fieldErrors.amount && <p className="text-xs text-red-600">{fieldErrors.amount}</p>}
|
|
171
|
+
</div>
|
|
172
|
+
<div className="input-group">
|
|
173
|
+
<label className="text-sm font-medium text-slate-700">Payment date</label>
|
|
174
|
+
<input type="date" className={inputClass} value={paymentDate} onChange={(e) => setPaymentDate(e.target.value)} />
|
|
175
|
+
{fieldErrors.paymentDate && <p className="text-xs text-red-600">{fieldErrors.paymentDate}</p>}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
<div className="form-actions">
|
|
179
|
+
<button type="submit" disabled={submitting} className="btn-primary">
|
|
180
|
+
{submitting ? 'Saving…' : 'Save'}
|
|
181
|
+
</button>
|
|
182
|
+
<button type="button" onClick={closeForm} className="btn-secondary">
|
|
183
|
+
Cancel
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
</form>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
<div className="table-shell">
|
|
190
|
+
<table className="min-w-full text-sm">
|
|
191
|
+
<thead className="table-head text-left">
|
|
192
|
+
<tr>
|
|
193
|
+
<th className="table-cell font-medium">Student</th>
|
|
194
|
+
<th className="table-cell font-medium">Amount</th>
|
|
195
|
+
<th className="table-cell font-medium">Date</th>
|
|
196
|
+
<th className="table-cell font-medium">Actions</th>
|
|
197
|
+
</tr>
|
|
198
|
+
</thead>
|
|
199
|
+
<tbody>
|
|
200
|
+
{list.map((p) => (
|
|
201
|
+
<tr key={p.id} className="table-row">
|
|
202
|
+
<td className="table-cell">
|
|
203
|
+
{p.student?.full_name || '—'} <span className="text-slate-500">({p.student?.class || '—'})</span>
|
|
204
|
+
</td>
|
|
205
|
+
<td className="table-cell font-semibold">{Number(p.amount).toLocaleString()} FRW</td>
|
|
206
|
+
<td className="table-cell">{p.payment_date}</td>
|
|
207
|
+
<td className="table-cell space-x-2 whitespace-nowrap">
|
|
208
|
+
<button type="button" className="btn-secondary" onClick={() => openEdit(p)}>
|
|
209
|
+
Edit
|
|
210
|
+
</button>
|
|
211
|
+
<button type="button" className="btn-destructive" onClick={() => remove(p.id)}>
|
|
212
|
+
Delete
|
|
213
|
+
</button>
|
|
214
|
+
</td>
|
|
215
|
+
</tr>
|
|
216
|
+
))}
|
|
217
|
+
</tbody>
|
|
218
|
+
</table>
|
|
219
|
+
{list.length === 0 && <p className="placeholder-card">No payments recorded.</p>}
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
{formTarget && formTarget !== 'new' && (
|
|
223
|
+
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" onClick={(e) => e.target === e.currentTarget && closeForm()} role="presentation">
|
|
224
|
+
<div className="form-card max-w-md w-full" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
|
|
225
|
+
<div className="flex items-center justify-between gap-4 mb-4">
|
|
226
|
+
<h2 className="text-xl font-semibold text-slate-900">Edit payment</h2>
|
|
227
|
+
<button type="button" onClick={closeForm} className="btn-secondary">
|
|
228
|
+
Close
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
<form onSubmit={save} className="space-y-4">
|
|
232
|
+
<div className="input-group">
|
|
233
|
+
<label className="text-sm font-medium text-slate-700">Student</label>
|
|
234
|
+
<select className={inputClass} value={studentId} onChange={(e) => setStudentId(e.target.value)}>
|
|
235
|
+
{students.map((s) => (
|
|
236
|
+
<option key={s.id} value={s.id}>
|
|
237
|
+
{s.full_name}
|
|
238
|
+
</option>
|
|
239
|
+
))}
|
|
240
|
+
</select>
|
|
241
|
+
</div>
|
|
242
|
+
<div className="input-group">
|
|
243
|
+
<label className="text-sm font-medium text-slate-700">Amount</label>
|
|
244
|
+
<input
|
|
245
|
+
type="text"
|
|
246
|
+
inputMode="numeric"
|
|
247
|
+
className={inputClass}
|
|
248
|
+
value={amount}
|
|
249
|
+
onChange={(e) => setAmount(e.target.value.replace(/\D/g, ''))}
|
|
250
|
+
placeholder="Numbers only"
|
|
251
|
+
/>
|
|
252
|
+
{fieldErrors.amount && <p className="text-xs text-red-600">{fieldErrors.amount}</p>}
|
|
253
|
+
</div>
|
|
254
|
+
<div className="input-group">
|
|
255
|
+
<label className="text-sm font-medium text-slate-700">Date</label>
|
|
256
|
+
<input type="date" className={inputClass} value={paymentDate} onChange={(e) => setPaymentDate(e.target.value)} />
|
|
257
|
+
</div>
|
|
258
|
+
<div className="form-actions">
|
|
259
|
+
<button type="submit" disabled={submitting} className="btn-primary w-full">
|
|
260
|
+
Save
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
</form>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { apiRequest } from '../api/apiClient.js';
|
|
3
|
+
|
|
4
|
+
export function ReportsPage() {
|
|
5
|
+
const [start, setStart] = useState('');
|
|
6
|
+
const [end, setEnd] = useState('');
|
|
7
|
+
const [report, setReport] = useState(null);
|
|
8
|
+
const [loading, setLoading] = useState(false);
|
|
9
|
+
const [error, setError] = useState('');
|
|
10
|
+
const [fieldErrors, setFieldErrors] = useState({});
|
|
11
|
+
|
|
12
|
+
function validate() {
|
|
13
|
+
const err = {};
|
|
14
|
+
if (!start) err.start = 'Start date is required';
|
|
15
|
+
if (!end) err.end = 'End date is required';
|
|
16
|
+
if (start && end && start > end) err.end = 'End date must be on or after start date';
|
|
17
|
+
setFieldErrors(err);
|
|
18
|
+
return Object.keys(err).length === 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function onSubmit(e) {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
if (!validate()) return;
|
|
24
|
+
setLoading(true);
|
|
25
|
+
setError('');
|
|
26
|
+
setReport(null);
|
|
27
|
+
try {
|
|
28
|
+
const q = new URLSearchParams({ start, end });
|
|
29
|
+
const data = await apiRequest(`/api/reports?${q}`);
|
|
30
|
+
setReport(data);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
setError(err.message);
|
|
33
|
+
} finally {
|
|
34
|
+
setLoading(false);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div>
|
|
40
|
+
<div className="app-header">
|
|
41
|
+
<p className="text-sm uppercase tracking-[0.3em] text-slate-500">Reports</p>
|
|
42
|
+
<h1 className="page-title">Payment summaries</h1>
|
|
43
|
+
<p className="page-subtitle">Choose a date range and get a neat report of collected fees.</p>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<form onSubmit={onSubmit} className="form-card grid gap-5 md:grid-cols-[1.1fr_1.1fr_auto] items-end">
|
|
47
|
+
<div className="input-group">
|
|
48
|
+
<label className="text-sm font-medium text-slate-700">Start date</label>
|
|
49
|
+
<input
|
|
50
|
+
type="date"
|
|
51
|
+
value={start}
|
|
52
|
+
onChange={(e) => setStart(e.target.value)}
|
|
53
|
+
className="input-field"
|
|
54
|
+
/>
|
|
55
|
+
{fieldErrors.start && <p className="text-xs text-red-600">{fieldErrors.start}</p>}
|
|
56
|
+
</div>
|
|
57
|
+
<div className="input-group">
|
|
58
|
+
<label className="text-sm font-medium text-slate-700">End date</label>
|
|
59
|
+
<input
|
|
60
|
+
type="date"
|
|
61
|
+
value={end}
|
|
62
|
+
onChange={(e) => setEnd(e.target.value)}
|
|
63
|
+
className="input-field"
|
|
64
|
+
/>
|
|
65
|
+
{fieldErrors.end && <p className="text-xs text-red-600">{fieldErrors.end}</p>}
|
|
66
|
+
</div>
|
|
67
|
+
<button type="submit" disabled={loading} className="btn-primary h-14 w-full md:w-auto">
|
|
68
|
+
{loading ? 'Loading…' : 'Run report'}
|
|
69
|
+
</button>
|
|
70
|
+
</form>
|
|
71
|
+
|
|
72
|
+
{error && (
|
|
73
|
+
<div className="mt-4 rounded-[26px] border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
74
|
+
{error}
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
|
|
78
|
+
{report && (
|
|
79
|
+
<div className="mt-8 space-y-6">
|
|
80
|
+
<div className="panel-card-alt flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
81
|
+
<p className="text-slate-600 text-sm">
|
|
82
|
+
Showing results from <strong>{report.start}</strong> to <strong>{report.end}</strong>
|
|
83
|
+
</p>
|
|
84
|
+
<p className="text-lg font-display font-bold text-sfms-ink">
|
|
85
|
+
Total: {Number(report.total_amount_paid).toLocaleString()} FRW
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="table-shell">
|
|
89
|
+
<table className="min-w-full text-sm">
|
|
90
|
+
<thead className="table-head text-left">
|
|
91
|
+
<tr>
|
|
92
|
+
<th className="table-cell font-medium">Student</th>
|
|
93
|
+
<th className="table-cell font-medium">Class</th>
|
|
94
|
+
<th className="table-cell font-medium">Amount</th>
|
|
95
|
+
<th className="table-cell font-medium">Payment date</th>
|
|
96
|
+
</tr>
|
|
97
|
+
</thead>
|
|
98
|
+
<tbody>
|
|
99
|
+
{report.payments.map((p) => (
|
|
100
|
+
<tr key={p.id} className="table-row">
|
|
101
|
+
<td className="table-cell">{p.student?.full_name || '—'}</td>
|
|
102
|
+
<td className="table-cell">{p.student?.class || '—'}</td>
|
|
103
|
+
<td className="table-cell font-semibold">{Number(p.amount).toLocaleString()} FRW</td>
|
|
104
|
+
<td className="table-cell">{p.payment_date}</td>
|
|
105
|
+
</tr>
|
|
106
|
+
))}
|
|
107
|
+
</tbody>
|
|
108
|
+
</table>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|