create-steve-rogers 1.0.0 → 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/SIMS/.env +4 -0
- package/apps/SIMS/README.md +138 -0
- package/apps/SIMS/backend/.env +4 -0
- package/apps/SIMS/backend/.env.example +4 -0
- package/apps/SIMS/backend/package-lock.json +1600 -0
- package/apps/SIMS/backend/package.json +22 -0
- package/apps/SIMS/backend/src/config/db.js +9 -0
- package/apps/SIMS/backend/src/controllers/authController.js +93 -0
- package/apps/SIMS/backend/src/controllers/simsReportController.js +94 -0
- package/apps/SIMS/backend/src/controllers/sparePartController.js +41 -0
- package/apps/SIMS/backend/src/controllers/stockInController.js +45 -0
- package/apps/SIMS/backend/src/controllers/stockOutController.js +123 -0
- package/apps/SIMS/backend/src/middleware/auth.js +8 -0
- package/apps/SIMS/backend/src/models/SparePart.js +17 -0
- package/apps/SIMS/backend/src/models/StockIn.js +16 -0
- package/apps/SIMS/backend/src/models/StockOut.js +18 -0
- package/apps/SIMS/backend/src/models/User.js +11 -0
- package/apps/SIMS/backend/src/routes/authRoutes.js +12 -0
- package/apps/SIMS/backend/src/routes/simsReportRoutes.js +8 -0
- package/apps/SIMS/backend/src/routes/sparePartRoutes.js +8 -0
- package/apps/SIMS/backend/src/routes/stockInRoutes.js +8 -0
- package/apps/SIMS/backend/src/routes/stockOutRoutes.js +10 -0
- package/apps/SIMS/backend/src/server.js +62 -0
- package/apps/SIMS/backend/src/utils/passwordPolicy.js +10 -0
- package/apps/SIMS/backend/src/utils/sparePartHelpers.js +5 -0
- package/apps/SIMS/frontend/dist/assets/index-3hv-vGL2.css +2 -0
- package/apps/SIMS/frontend/dist/assets/index-T8XT7M6y.js +19 -0
- package/apps/SIMS/frontend/dist/index.html +14 -0
- package/apps/SIMS/frontend/index.html +13 -0
- package/apps/SIMS/frontend/package-lock.json +3053 -0
- package/apps/SIMS/frontend/package.json +31 -0
- package/apps/SIMS/frontend/src/App.jsx +112 -0
- package/apps/SIMS/frontend/src/api/authApi.js +7 -0
- package/apps/SIMS/frontend/src/api/client.js +8 -0
- package/apps/SIMS/frontend/src/api/simsReportApi.js +5 -0
- package/apps/SIMS/frontend/src/api/sparePartsApi.js +4 -0
- package/apps/SIMS/frontend/src/api/stockInApi.js +4 -0
- package/apps/SIMS/frontend/src/api/stockOutApi.js +6 -0
- package/apps/SIMS/frontend/src/api/usersApi.js +3 -0
- package/apps/SIMS/frontend/src/components/AppLayout.jsx +60 -0
- package/apps/SIMS/frontend/src/index.css +737 -0
- package/apps/SIMS/frontend/src/main.jsx +13 -0
- package/apps/SIMS/frontend/src/pages/DashboardPage.jsx +179 -0
- package/apps/SIMS/frontend/src/pages/LoginPage.jsx +75 -0
- package/apps/SIMS/frontend/src/pages/RegisterPage.jsx +78 -0
- package/apps/SIMS/frontend/src/pages/ReportsPage.jsx +108 -0
- package/apps/SIMS/frontend/src/pages/ResetPasswordPage.jsx +75 -0
- package/apps/SIMS/frontend/src/pages/SparePartPage.jsx +128 -0
- package/apps/SIMS/frontend/src/pages/StockInPage.jsx +100 -0
- package/apps/SIMS/frontend/src/pages/StockOutPage.jsx +206 -0
- package/apps/SIMS/frontend/src/utils/passwordPolicy.js +8 -0
- package/apps/SIMS/frontend/vite.config.js +8 -0
- package/apps/config.js +13 -0
- package/package.json +1 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sfms-frontend",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"react": "^18.3.1",
|
|
13
|
+
"react-dom": "^18.3.1",
|
|
14
|
+
"react-router-dom": "^6.26.2"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@vitejs/plugin-react": "^4.3.2",
|
|
18
|
+
"autoprefixer": "^10.4.20",
|
|
19
|
+
"postcss": "^8.4.47",
|
|
20
|
+
"tailwindcss": "^3.4.13",
|
|
21
|
+
"vite": "^5.4.8"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
|
2
|
+
import { useAuth } from './context/AuthContext.jsx';
|
|
3
|
+
import { AppLayout } from './components/AppLayout.jsx';
|
|
4
|
+
import { LoginPage } from './pages/LoginPage.jsx';
|
|
5
|
+
import { DashboardPage } from './pages/DashboardPage.jsx';
|
|
6
|
+
import { StudentsPage } from './pages/StudentsPage.jsx';
|
|
7
|
+
import { PaymentsPage } from './pages/PaymentsPage.jsx';
|
|
8
|
+
import { ReportsPage } from './pages/ReportsPage.jsx';
|
|
9
|
+
|
|
10
|
+
function PrivateRoute({ children }) {
|
|
11
|
+
const { isAuthenticated } = useAuth();
|
|
12
|
+
const location = useLocation();
|
|
13
|
+
|
|
14
|
+
if (!isAuthenticated) {
|
|
15
|
+
return <Navigate to="/login" state={{ from: location }} replace />;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return children;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default function App() {
|
|
22
|
+
return (
|
|
23
|
+
<Routes>
|
|
24
|
+
<Route path="/login" element={<LoginPage />} />
|
|
25
|
+
<Route
|
|
26
|
+
path="/"
|
|
27
|
+
element={
|
|
28
|
+
<PrivateRoute>
|
|
29
|
+
<AppLayout />
|
|
30
|
+
</PrivateRoute>
|
|
31
|
+
}
|
|
32
|
+
>
|
|
33
|
+
<Route index element={<DashboardPage />} />
|
|
34
|
+
<Route path="students" element={<StudentsPage />} />
|
|
35
|
+
<Route path="payments" element={<PaymentsPage />} />
|
|
36
|
+
<Route path="reports" element={<ReportsPage />} />
|
|
37
|
+
</Route>
|
|
38
|
+
<Route path="*" element={<Navigate to="/" replace />} />
|
|
39
|
+
</Routes>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** HTTP client with JWT for SFMS API */
|
|
2
|
+
|
|
3
|
+
const API_BASE = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/+$/, '');
|
|
4
|
+
|
|
5
|
+
function apiUrl(path) {
|
|
6
|
+
if (path.startsWith('http')) return path;
|
|
7
|
+
const p = path.startsWith('/') ? path : `/${path}`;
|
|
8
|
+
return API_BASE ? `${API_BASE}${p}` : p;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getToken() {
|
|
12
|
+
return sessionStorage.getItem('sfms_token');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function apiRequest(path, { method = 'GET', body, headers = {} } = {}) {
|
|
16
|
+
const opts = {
|
|
17
|
+
method,
|
|
18
|
+
headers: {
|
|
19
|
+
'Content-Type': 'application/json',
|
|
20
|
+
...headers,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
const token = getToken();
|
|
24
|
+
if (token) opts.headers.Authorization = `Bearer ${token}`;
|
|
25
|
+
if (body !== undefined) opts.body = body;
|
|
26
|
+
|
|
27
|
+
const resp = await fetch(apiUrl(path), opts);
|
|
28
|
+
const text = await resp.text();
|
|
29
|
+
let data = null;
|
|
30
|
+
try {
|
|
31
|
+
data = text ? JSON.parse(text) : null;
|
|
32
|
+
} catch {
|
|
33
|
+
data = { message: text || 'Request error' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!resp.ok) {
|
|
37
|
+
const msg = data?.message || `Error ${resp.status}`;
|
|
38
|
+
throw new Error(msg);
|
|
39
|
+
}
|
|
40
|
+
return data;
|
|
41
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { NavLink, Outlet } from 'react-router-dom';
|
|
2
|
+
import { useAuth } from '../context/AuthContext.jsx';
|
|
3
|
+
|
|
4
|
+
const navItems = [
|
|
5
|
+
{ to: '/', label: 'Dashboard' },
|
|
6
|
+
{ to: '/students', label: 'Students' },
|
|
7
|
+
{ to: '/payments', label: 'Payments' },
|
|
8
|
+
{ to: '/reports', label: 'Reports' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export function AppLayout() {
|
|
12
|
+
const { user, logout } = useAuth();
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="app-shell">
|
|
16
|
+
<aside className="app-sidebar">
|
|
17
|
+
<div className="app-brand">
|
|
18
|
+
<p className="app-brand-name">SFMS</p>
|
|
19
|
+
<p className="app-brand-subtitle">School fee management</p>
|
|
20
|
+
</div>
|
|
21
|
+
<nav className="app-nav">
|
|
22
|
+
{navItems.map((item) => (
|
|
23
|
+
<NavLink
|
|
24
|
+
key={item.to}
|
|
25
|
+
to={item.to}
|
|
26
|
+
end={item.to === '/'}
|
|
27
|
+
className={({ isActive }) =>
|
|
28
|
+
`app-nav-link ${isActive ? 'active' : ''}`
|
|
29
|
+
}
|
|
30
|
+
>
|
|
31
|
+
{item.label}
|
|
32
|
+
</NavLink>
|
|
33
|
+
))}
|
|
34
|
+
</nav>
|
|
35
|
+
<div className="app-user-panel">
|
|
36
|
+
<p className="app-user-name" title={user?.email}>
|
|
37
|
+
{user?.name || 'Administrator'}
|
|
38
|
+
</p>
|
|
39
|
+
<button type="button" onClick={logout} className="app-user-action">
|
|
40
|
+
Log out
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
</aside>
|
|
44
|
+
<main className="app-content">
|
|
45
|
+
<div className="app-header panel-card">
|
|
46
|
+
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
47
|
+
<div>
|
|
48
|
+
<p className="text-sm uppercase tracking-[0.35em] text-slate-500">Control center</p>
|
|
49
|
+
<h1 className="page-title">Manage school fees with clarity</h1>
|
|
50
|
+
</div>
|
|
51
|
+
<button type="button" onClick={logout} className="btn-secondary">
|
|
52
|
+
Sign out
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
<Outlet />
|
|
57
|
+
</main>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { apiRequest } from '../api/apiClient.js';
|
|
3
|
+
|
|
4
|
+
const AuthContext = createContext(null);
|
|
5
|
+
|
|
6
|
+
const TOKEN_KEY = 'sfms_token';
|
|
7
|
+
const USER_KEY = 'sfms_user';
|
|
8
|
+
|
|
9
|
+
export function AuthProvider({ children }) {
|
|
10
|
+
const [token, setToken] = useState(() => sessionStorage.getItem(TOKEN_KEY));
|
|
11
|
+
const [user, setUser] = useState(() => {
|
|
12
|
+
const raw = sessionStorage.getItem(USER_KEY);
|
|
13
|
+
try {
|
|
14
|
+
return raw ? JSON.parse(raw) : null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (token) sessionStorage.setItem(TOKEN_KEY, token);
|
|
22
|
+
else sessionStorage.removeItem(TOKEN_KEY);
|
|
23
|
+
}, [token]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (user) sessionStorage.setItem(USER_KEY, JSON.stringify(user));
|
|
27
|
+
else sessionStorage.removeItem(USER_KEY);
|
|
28
|
+
}, [user]);
|
|
29
|
+
|
|
30
|
+
const login = useCallback(async (email, password) => {
|
|
31
|
+
const data = await apiRequest('/api/login', {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
body: JSON.stringify({ email, password }),
|
|
34
|
+
});
|
|
35
|
+
setToken(data.token);
|
|
36
|
+
setUser(data.user);
|
|
37
|
+
return data;
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
const register = useCallback(async (name, email, password, role) => {
|
|
41
|
+
const data = await apiRequest('/api/register', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
name,
|
|
45
|
+
email,
|
|
46
|
+
password,
|
|
47
|
+
role: role || 'admin',
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
setToken(data.token);
|
|
51
|
+
setUser(data.user);
|
|
52
|
+
return data;
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
const logout = useCallback(() => {
|
|
56
|
+
setToken(null);
|
|
57
|
+
setUser(null);
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
const value = useMemo(
|
|
61
|
+
() => ({
|
|
62
|
+
token,
|
|
63
|
+
user,
|
|
64
|
+
login,
|
|
65
|
+
register,
|
|
66
|
+
logout,
|
|
67
|
+
isAuthenticated: Boolean(token),
|
|
68
|
+
}),
|
|
69
|
+
[token, user, login, register, logout]
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function useAuth() {
|
|
76
|
+
const ctx = useContext(AuthContext);
|
|
77
|
+
if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
|
|
78
|
+
return ctx;
|
|
79
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
@layer base {
|
|
6
|
+
:root {
|
|
7
|
+
color-scheme: light;
|
|
8
|
+
--surface: #f8fafc;
|
|
9
|
+
--panel: rgba(255,255,255,0.94);
|
|
10
|
+
--border: rgba(148,163,184,0.24);
|
|
11
|
+
--text-title: #0f172a;
|
|
12
|
+
--text-body: #475569;
|
|
13
|
+
--accent: #0d9488;
|
|
14
|
+
--accent-dark: #0f766e;
|
|
15
|
+
--shadow-soft: 0 24px 80px rgba(15,23,42,0.08);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
html {
|
|
19
|
+
scroll-behavior: smooth;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
body {
|
|
23
|
+
@apply min-h-screen bg-slate-50 text-slate-900 antialiased;
|
|
24
|
+
background-image:
|
|
25
|
+
radial-gradient(circle at top left, rgba(13,148,136,0.18), transparent 24%),
|
|
26
|
+
radial-gradient(circle at bottom right, rgba(14,165,233,0.11), transparent 20%),
|
|
27
|
+
linear-gradient(180deg, #f8fbfd 0%, #f1f5f9 100%);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
* {
|
|
31
|
+
box-sizing: border-box;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@layer components {
|
|
36
|
+
.app-shell {
|
|
37
|
+
@apply min-h-screen flex flex-col md:flex-row bg-transparent;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.app-sidebar {
|
|
41
|
+
@apply w-full md:w-72 flex-shrink-0 border backdrop-blur-xl shadow-sm;
|
|
42
|
+
border-color: rgba(148,163,184,0.7);
|
|
43
|
+
background-color: rgba(255,255,255,0.92);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.app-brand {
|
|
47
|
+
@apply p-6 border-b;
|
|
48
|
+
border-color: rgba(148,163,184,0.7);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.app-brand-name {
|
|
52
|
+
@apply font-display text-2xl font-extrabold tracking-tight text-slate-900;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.app-brand-subtitle {
|
|
56
|
+
@apply mt-1 text-sm text-slate-500;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.app-nav {
|
|
60
|
+
@apply p-4 flex flex-wrap gap-3;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.app-nav-link {
|
|
64
|
+
@apply rounded-full px-4 py-2 text-sm font-semibold text-slate-600 transition duration-200;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.app-nav-link:hover {
|
|
68
|
+
@apply bg-slate-100 text-slate-900;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.app-nav-link.active {
|
|
72
|
+
@apply bg-sfms-ink text-white shadow-lg;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.app-user-panel {
|
|
76
|
+
@apply mt-auto p-6 border-t;
|
|
77
|
+
border-color: rgba(148,163,184,0.7);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.app-user-name {
|
|
81
|
+
@apply text-sm text-slate-600 truncate;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.app-user-action {
|
|
85
|
+
@apply mt-3 w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-100;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.app-content {
|
|
89
|
+
@apply flex-1 px-4 pb-8 pt-5 md:px-8 md:pt-8;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.app-header {
|
|
93
|
+
@apply mb-6;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.page-title {
|
|
97
|
+
@apply font-display text-3xl md:text-4xl font-bold text-slate-900 tracking-tight;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.page-subtitle {
|
|
101
|
+
@apply mt-3 text-slate-600 max-w-2xl leading-7;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.panel-card {
|
|
105
|
+
@apply rounded-[32px] border shadow-[0_24px_80px_rgba(15,23,42,0.08)] p-6;
|
|
106
|
+
border-color: rgba(148,163,184,0.8);
|
|
107
|
+
background-color: rgba(255,255,255,0.95);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.panel-card-alt {
|
|
111
|
+
@apply rounded-[32px] border p-6;
|
|
112
|
+
border-color: rgba(148,163,184,0.7);
|
|
113
|
+
background-color: rgba(248,250,252,0.9);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.stat-grid {
|
|
117
|
+
@apply grid gap-5 mt-8 sm:grid-cols-3;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.stat-card {
|
|
121
|
+
@apply rounded-[28px] border bg-gradient-to-br from-white to-slate-50 p-6 shadow-sm;
|
|
122
|
+
border-color: rgba(148,163,184,0.7);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.stat-label {
|
|
126
|
+
@apply text-xs font-semibold uppercase tracking-[0.24em] text-slate-500;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.stat-value {
|
|
130
|
+
@apply mt-4 text-3xl font-bold text-slate-900;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.stat-link {
|
|
134
|
+
@apply mt-5 inline-flex text-sm font-medium text-slate-600 transition hover:text-slate-900;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.form-card {
|
|
138
|
+
@apply rounded-[32px] border shadow-[0_20px_55px_rgba(15,23,42,0.06)] p-6;
|
|
139
|
+
border-color: rgba(148,163,184,0.8);
|
|
140
|
+
background-color: rgba(255,255,255,0.95);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.input-field {
|
|
144
|
+
@apply w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-slate-900 outline-none transition duration-200 focus:border-sfms-accent focus:ring-2 focus:ring-sfms-accent;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.input-group {
|
|
148
|
+
@apply space-y-2;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.form-actions {
|
|
152
|
+
@apply flex flex-wrap gap-3;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.btn-primary {
|
|
156
|
+
@apply inline-flex items-center justify-center rounded-2xl bg-sfms-accent px-5 py-3 text-sm font-semibold text-white shadow-lg transition duration-200 hover:bg-sfms-accentDark;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.btn-secondary {
|
|
160
|
+
@apply inline-flex items-center justify-center rounded-2xl border border-slate-300 bg-white px-5 py-3 text-sm font-semibold text-slate-700 transition duration-200 hover:bg-slate-100;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.btn-destructive {
|
|
164
|
+
@apply inline-flex items-center justify-center rounded-2xl bg-red-600 px-5 py-3 text-sm font-semibold text-white transition duration-200 hover:bg-red-700;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.table-shell {
|
|
168
|
+
@apply overflow-x-auto rounded-[28px] border border-slate-200 bg-white/95 shadow-sm;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.table-head {
|
|
172
|
+
@apply bg-slate-100 text-slate-600;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.table-row {
|
|
176
|
+
@apply border-t border-slate-100 transition duration-200 hover:bg-slate-50;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.table-cell {
|
|
180
|
+
@apply px-4 py-4;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.placeholder-card {
|
|
184
|
+
@apply rounded-[28px] border border-dashed border-slate-300 bg-slate-50 px-6 py-12 text-center text-slate-500;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.auth-page {
|
|
188
|
+
@apply min-h-screen flex items-center justify-center px-4 py-10;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.auth-card {
|
|
192
|
+
@apply w-full max-w-4xl overflow-hidden rounded-[40px] border shadow-[0_30px_120px_rgba(15,23,42,0.12)];
|
|
193
|
+
border-color: rgba(148,163,184,0.8);
|
|
194
|
+
background-color: rgba(255,255,255,0.95);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.auth-split {
|
|
198
|
+
@apply grid gap-6 md:grid-cols-[1.1fr_0.9fr];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.auth-side {
|
|
202
|
+
@apply hidden md:flex flex-col justify-center gap-4 rounded-tr-[40px] rounded-br-[40px] bg-sfms-accent px-10 py-12 text-white;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.auth-logo {
|
|
206
|
+
@apply text-4xl font-display font-black;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.auth-copy {
|
|
210
|
+
@apply mt-4 text-sm leading-7;
|
|
211
|
+
color: rgba(241,245,249,0.9);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.auth-panel {
|
|
215
|
+
@apply p-8 sm:p-10;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.auth-switcher {
|
|
219
|
+
@apply flex gap-3 rounded-full bg-slate-100 p-1;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.auth-switcher button {
|
|
223
|
+
@apply flex-1 rounded-full px-4 py-2 text-sm font-semibold transition duration-200;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.auth-switcher button.active {
|
|
227
|
+
@apply bg-white text-slate-900 shadow-sm;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import ReactDOM from 'react-dom/client';
|
|
3
|
+
import { BrowserRouter } from 'react-router-dom';
|
|
4
|
+
import App from './App.jsx';
|
|
5
|
+
import { AuthProvider } from './context/AuthContext.jsx';
|
|
6
|
+
import './index.css';
|
|
7
|
+
|
|
8
|
+
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
9
|
+
<React.StrictMode>
|
|
10
|
+
<BrowserRouter>
|
|
11
|
+
<AuthProvider>
|
|
12
|
+
<App />
|
|
13
|
+
</AuthProvider>
|
|
14
|
+
</BrowserRouter>
|
|
15
|
+
</React.StrictMode>
|
|
16
|
+
);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Link } from 'react-router-dom';
|
|
3
|
+
import { apiRequest } from '../api/apiClient.js';
|
|
4
|
+
|
|
5
|
+
export function DashboardPage() {
|
|
6
|
+
const [studentCount, setStudentCount] = useState(null);
|
|
7
|
+
const [paymentCount, setPaymentCount] = useState(null);
|
|
8
|
+
const [totalPaid, setTotalPaid] = useState(null);
|
|
9
|
+
const [error, setError] = useState('');
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
let cancelled = false;
|
|
13
|
+
(async () => {
|
|
14
|
+
try {
|
|
15
|
+
const [students, payments] = await Promise.all([
|
|
16
|
+
apiRequest('/api/students'),
|
|
17
|
+
apiRequest('/api/payments'),
|
|
18
|
+
]);
|
|
19
|
+
if (cancelled) return;
|
|
20
|
+
setStudentCount(students.length);
|
|
21
|
+
setPaymentCount(payments.length);
|
|
22
|
+
setTotalPaid(payments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0));
|
|
23
|
+
} catch (e) {
|
|
24
|
+
if (!cancelled) setError(e.message);
|
|
25
|
+
}
|
|
26
|
+
})();
|
|
27
|
+
return () => {
|
|
28
|
+
cancelled = true;
|
|
29
|
+
};
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
const cards = [
|
|
33
|
+
{
|
|
34
|
+
label: 'Students',
|
|
35
|
+
value: studentCount ?? '—',
|
|
36
|
+
to: '/students',
|
|
37
|
+
accent: 'from-slate-50 to-slate-100',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
label: 'Payments',
|
|
41
|
+
value: paymentCount ?? '—',
|
|
42
|
+
to: '/payments',
|
|
43
|
+
accent: 'from-slate-50 to-teal-50',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
label: 'Total paid',
|
|
47
|
+
value: totalPaid != null ? `${totalPaid.toLocaleString()} FRW` : '—',
|
|
48
|
+
to: '/reports',
|
|
49
|
+
accent: 'from-slate-50 to-cyan-50',
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div>
|
|
55
|
+
<div className="app-header">
|
|
56
|
+
<p className="text-sm uppercase tracking-[0.3em] text-slate-500">Dashboard</p>
|
|
57
|
+
<h1 className="page-title">Overview for today</h1>
|
|
58
|
+
<p className="page-subtitle">Track the latest student count, payment volume, and total receipts at a glance.</p>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
{error && (
|
|
62
|
+
<p className="mb-6 rounded-[28px] border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
|
63
|
+
{error}
|
|
64
|
+
</p>
|
|
65
|
+
)}
|
|
66
|
+
|
|
67
|
+
<div className="stat-grid">
|
|
68
|
+
{cards.map((card) => (
|
|
69
|
+
<Link
|
|
70
|
+
key={card.label}
|
|
71
|
+
to={card.to}
|
|
72
|
+
className={`stat-card bg-gradient-to-br ${card.accent} border-slate-200`}
|
|
73
|
+
>
|
|
74
|
+
<p className="stat-label">{card.label}</p>
|
|
75
|
+
<p className="stat-value">{card.value}</p>
|
|
76
|
+
<span className="stat-link">Open details →</span>
|
|
77
|
+
</Link>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|