create-sales 1.0.0
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/.gitignore +19 -0
- package/README.md +40 -0
- package/backend-project/.env.example +11 -0
- package/backend-project/database/schema.sql +101 -0
- package/backend-project/db.js +13 -0
- package/backend-project/middleware/auth.js +24 -0
- package/backend-project/package.json +21 -0
- package/backend-project/routes/auth.js +55 -0
- package/backend-project/routes/bookings.js +151 -0
- package/backend-project/routes/buses.js +81 -0
- package/backend-project/routes/schedules.js +95 -0
- package/backend-project/routes/users.js +86 -0
- package/backend-project/server.js +54 -0
- package/bin/create-sales.js +102 -0
- package/frontend-project/index.html +12 -0
- package/frontend-project/package.json +31 -0
- package/frontend-project/src/App.tsx +77 -0
- package/frontend-project/src/api/auth.ts +20 -0
- package/frontend-project/src/api/axios.ts +11 -0
- package/frontend-project/src/api/bookings.ts +31 -0
- package/frontend-project/src/api/buses.ts +26 -0
- package/frontend-project/src/api/schedules.ts +26 -0
- package/frontend-project/src/api/users.ts +21 -0
- package/frontend-project/src/components/common/Layout.tsx +15 -0
- package/frontend-project/src/components/common/Modal.tsx +42 -0
- package/frontend-project/src/components/common/Navbar.tsx +124 -0
- package/frontend-project/src/components/common/ProtectedRoute.tsx +30 -0
- package/frontend-project/src/components/common/StatCard.tsx +24 -0
- package/frontend-project/src/components/common/Table.tsx +67 -0
- package/frontend-project/src/context/AuthContext.tsx +41 -0
- package/frontend-project/src/index.css +43 -0
- package/frontend-project/src/main.tsx +10 -0
- package/frontend-project/src/pages/Bookings.tsx +295 -0
- package/frontend-project/src/pages/Buses.tsx +255 -0
- package/frontend-project/src/pages/Dashboard.tsx +197 -0
- package/frontend-project/src/pages/Login.tsx +112 -0
- package/frontend-project/src/pages/Report.tsx +252 -0
- package/frontend-project/src/pages/Schedules.tsx +256 -0
- package/frontend-project/src/pages/Users.tsx +199 -0
- package/frontend-project/src/types/index.ts +63 -0
- package/frontend-project/src/utils/cn.ts +6 -0
- package/frontend-project/tsconfig.json +31 -0
- package/frontend-project/vite.config.ts +19 -0
- package/package.json +50 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { getBuses, createBus, updateBus, deleteBus } from '../api/buses';
|
|
3
|
+
import { Bus } from '../types';
|
|
4
|
+
import Table from '../components/common/Table';
|
|
5
|
+
import Modal from '../components/common/Modal';
|
|
6
|
+
|
|
7
|
+
const busTypes = ['Standard', 'Express', 'Luxury', 'Mini'];
|
|
8
|
+
|
|
9
|
+
const defaultForm: Omit<Bus, 'BusID'> = {
|
|
10
|
+
PlateNumber: '',
|
|
11
|
+
TotalSeats: 30,
|
|
12
|
+
BusType: 'Standard',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const Buses = () => {
|
|
16
|
+
const [buses, setBuses] = useState<Bus[]>([]);
|
|
17
|
+
const [loading, setLoading] = useState(true);
|
|
18
|
+
const [modalOpen, setModalOpen] = useState(false);
|
|
19
|
+
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
|
20
|
+
const [editBus, setEditBus] = useState<Bus | null>(null);
|
|
21
|
+
const [deleteBusId, setDeleteBusId] = useState<number | null>(null);
|
|
22
|
+
const [form, setForm] = useState(defaultForm);
|
|
23
|
+
const [saving, setSaving] = useState(false);
|
|
24
|
+
const [error, setError] = useState('');
|
|
25
|
+
const [search, setSearch] = useState('');
|
|
26
|
+
|
|
27
|
+
const fetchBuses = async () => {
|
|
28
|
+
setLoading(true);
|
|
29
|
+
try {
|
|
30
|
+
const data = await getBuses();
|
|
31
|
+
setBuses(data);
|
|
32
|
+
} finally {
|
|
33
|
+
setLoading(false);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
useEffect(() => { fetchBuses(); }, []);
|
|
38
|
+
|
|
39
|
+
const openAdd = () => {
|
|
40
|
+
setEditBus(null);
|
|
41
|
+
setForm(defaultForm);
|
|
42
|
+
setError('');
|
|
43
|
+
setModalOpen(true);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const openEdit = (bus: Bus) => {
|
|
47
|
+
setEditBus(bus);
|
|
48
|
+
setForm({ PlateNumber: bus.PlateNumber, TotalSeats: bus.TotalSeats, BusType: bus.BusType });
|
|
49
|
+
setError('');
|
|
50
|
+
setModalOpen(true);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const openDelete = (id: number) => {
|
|
54
|
+
setDeleteBusId(id);
|
|
55
|
+
setDeleteModalOpen(true);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
setSaving(true);
|
|
61
|
+
setError('');
|
|
62
|
+
try {
|
|
63
|
+
if (editBus) {
|
|
64
|
+
await updateBus(editBus.BusID, form);
|
|
65
|
+
} else {
|
|
66
|
+
await createBus(form);
|
|
67
|
+
}
|
|
68
|
+
setModalOpen(false);
|
|
69
|
+
fetchBuses();
|
|
70
|
+
} catch (err: unknown) {
|
|
71
|
+
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
72
|
+
} finally {
|
|
73
|
+
setSaving(false);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const handleDelete = async () => {
|
|
78
|
+
if (!deleteBusId) return;
|
|
79
|
+
try {
|
|
80
|
+
await deleteBus(deleteBusId);
|
|
81
|
+
setDeleteModalOpen(false);
|
|
82
|
+
fetchBuses();
|
|
83
|
+
} catch (err: unknown) {
|
|
84
|
+
alert(err instanceof Error ? err.message : 'Delete failed');
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const filtered = buses.filter(
|
|
89
|
+
(b) =>
|
|
90
|
+
b.PlateNumber.toLowerCase().includes(search.toLowerCase()) ||
|
|
91
|
+
b.BusType.toLowerCase().includes(search.toLowerCase())
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const columns = [
|
|
95
|
+
{ key: 'BusID', label: '#' },
|
|
96
|
+
{ key: 'PlateNumber', label: 'Plate Number' },
|
|
97
|
+
{ key: 'TotalSeats', label: 'Total Seats' },
|
|
98
|
+
{ key: 'BusType', label: 'Bus Type', render: (bus: Bus) => (
|
|
99
|
+
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
|
100
|
+
bus.BusType === 'Luxury' ? 'bg-purple-100 text-purple-700'
|
|
101
|
+
: bus.BusType === 'Express' ? 'bg-blue-100 text-blue-700'
|
|
102
|
+
: bus.BusType === 'Mini' ? 'bg-orange-100 text-orange-700'
|
|
103
|
+
: 'bg-gray-100 text-gray-700'
|
|
104
|
+
}`}>{bus.BusType}</span>
|
|
105
|
+
)},
|
|
106
|
+
{
|
|
107
|
+
key: 'actions',
|
|
108
|
+
label: 'Actions',
|
|
109
|
+
render: (bus: Bus) => (
|
|
110
|
+
<div className="flex gap-2">
|
|
111
|
+
<button
|
|
112
|
+
onClick={() => openEdit(bus)}
|
|
113
|
+
className="bg-blue-50 hover:bg-blue-100 text-blue-700 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
|
114
|
+
>
|
|
115
|
+
Edit
|
|
116
|
+
</button>
|
|
117
|
+
<button
|
|
118
|
+
onClick={() => openDelete(bus.BusID)}
|
|
119
|
+
className="bg-red-50 hover:bg-red-100 text-red-700 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
|
120
|
+
>
|
|
121
|
+
Delete
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
),
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className="space-y-5">
|
|
130
|
+
{/* Header */}
|
|
131
|
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
132
|
+
<div>
|
|
133
|
+
<h1 className="text-2xl font-bold text-gray-800">Bus Fleet Management</h1>
|
|
134
|
+
<p className="text-gray-500 text-sm mt-1">Manage all buses in the Y-Bus fleet</p>
|
|
135
|
+
</div>
|
|
136
|
+
<button
|
|
137
|
+
onClick={openAdd}
|
|
138
|
+
className="bg-gray-900 hover:bg-gray-700 text-yellow-400 px-5 py-2.5 rounded-xl text-sm font-semibold flex items-center gap-2 transition-colors shadow-sm"
|
|
139
|
+
>
|
|
140
|
+
<span>+ Add New Bus</span>
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{/* Search & Stats */}
|
|
145
|
+
<div className="flex flex-col sm:flex-row gap-3">
|
|
146
|
+
<input
|
|
147
|
+
type="text"
|
|
148
|
+
placeholder="Search by plate number or bus type..."
|
|
149
|
+
value={search}
|
|
150
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
151
|
+
className="flex-1 border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-yellow-400"
|
|
152
|
+
/>
|
|
153
|
+
<div className="flex gap-3">
|
|
154
|
+
{busTypes.map((type) => (
|
|
155
|
+
<span key={type} className="hidden sm:flex items-center gap-1 text-xs text-gray-500 bg-gray-50 px-3 py-1.5 rounded-full border">
|
|
156
|
+
{buses.filter(b => b.BusType === type).length} {type}
|
|
157
|
+
</span>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<Table
|
|
163
|
+
columns={columns}
|
|
164
|
+
data={filtered}
|
|
165
|
+
keyExtractor={(bus) => bus.BusID}
|
|
166
|
+
loading={loading}
|
|
167
|
+
emptyMessage="No buses found. Add your first bus!"
|
|
168
|
+
/>
|
|
169
|
+
|
|
170
|
+
{/* Add/Edit Modal */}
|
|
171
|
+
<Modal
|
|
172
|
+
isOpen={modalOpen}
|
|
173
|
+
onClose={() => setModalOpen(false)}
|
|
174
|
+
title={editBus ? 'Edit Bus' : 'Add New Bus'}
|
|
175
|
+
>
|
|
176
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
177
|
+
{error && (
|
|
178
|
+
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>
|
|
179
|
+
)}
|
|
180
|
+
<div>
|
|
181
|
+
<label className="block text-sm font-medium text-gray-700 mb-1.5">Plate Number *</label>
|
|
182
|
+
<input
|
|
183
|
+
type="text"
|
|
184
|
+
required
|
|
185
|
+
value={form.PlateNumber}
|
|
186
|
+
onChange={(e) => setForm({ ...form, PlateNumber: e.target.value.toUpperCase() })}
|
|
187
|
+
className="w-full border border-gray-200 rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-yellow-400"
|
|
188
|
+
placeholder="e.g. RAD 123 A"
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
<div>
|
|
192
|
+
<label className="block text-sm font-medium text-gray-700 mb-1.5">Total Seats *</label>
|
|
193
|
+
<input
|
|
194
|
+
type="number"
|
|
195
|
+
required
|
|
196
|
+
min={1}
|
|
197
|
+
max={100}
|
|
198
|
+
value={form.TotalSeats}
|
|
199
|
+
onChange={(e) => setForm({ ...form, TotalSeats: parseInt(e.target.value) })}
|
|
200
|
+
className="w-full border border-gray-200 rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-yellow-400"
|
|
201
|
+
/>
|
|
202
|
+
</div>
|
|
203
|
+
<div>
|
|
204
|
+
<label className="block text-sm font-medium text-gray-700 mb-1.5">Bus Type *</label>
|
|
205
|
+
<select
|
|
206
|
+
required
|
|
207
|
+
value={form.BusType}
|
|
208
|
+
onChange={(e) => setForm({ ...form, BusType: e.target.value })}
|
|
209
|
+
className="w-full border border-gray-200 rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-yellow-400"
|
|
210
|
+
>
|
|
211
|
+
{busTypes.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
212
|
+
</select>
|
|
213
|
+
</div>
|
|
214
|
+
<div className="flex gap-3 pt-2">
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
onClick={() => setModalOpen(false)}
|
|
218
|
+
className="flex-1 border border-gray-200 text-gray-700 py-2.5 rounded-lg text-sm hover:bg-gray-50 transition-colors"
|
|
219
|
+
>
|
|
220
|
+
Cancel
|
|
221
|
+
</button>
|
|
222
|
+
<button
|
|
223
|
+
type="submit"
|
|
224
|
+
disabled={saving}
|
|
225
|
+
className="flex-1 bg-gray-900 text-yellow-400 py-2.5 rounded-lg text-sm font-semibold hover:bg-gray-700 transition-colors disabled:opacity-50"
|
|
226
|
+
>
|
|
227
|
+
{saving ? 'Saving...' : editBus ? 'Update Bus' : 'Add Bus'}
|
|
228
|
+
</button>
|
|
229
|
+
</div>
|
|
230
|
+
</form>
|
|
231
|
+
</Modal>
|
|
232
|
+
|
|
233
|
+
{/* Delete Modal */}
|
|
234
|
+
<Modal isOpen={deleteModalOpen} onClose={() => setDeleteModalOpen(false)} title="Confirm Delete" size="sm">
|
|
235
|
+
<p className="text-gray-600 text-sm mb-4">Are you sure you want to delete this bus? This action cannot be undone.</p>
|
|
236
|
+
<div className="flex gap-3">
|
|
237
|
+
<button
|
|
238
|
+
onClick={() => setDeleteModalOpen(false)}
|
|
239
|
+
className="flex-1 border border-gray-200 text-gray-700 py-2.5 rounded-lg text-sm hover:bg-gray-50"
|
|
240
|
+
>
|
|
241
|
+
Cancel
|
|
242
|
+
</button>
|
|
243
|
+
<button
|
|
244
|
+
onClick={handleDelete}
|
|
245
|
+
className="flex-1 bg-red-600 text-white py-2.5 rounded-lg text-sm font-semibold hover:bg-red-700"
|
|
246
|
+
>
|
|
247
|
+
Delete
|
|
248
|
+
</button>
|
|
249
|
+
</div>
|
|
250
|
+
</Modal>
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
export default Buses;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { Link } from 'react-router-dom';
|
|
3
|
+
import { getBuses } from '../api/buses';
|
|
4
|
+
import { getSchedules } from '../api/schedules';
|
|
5
|
+
import { getBookings } from '../api/bookings';
|
|
6
|
+
import { getUsers } from '../api/users';
|
|
7
|
+
import { useAuth } from '../context/AuthContext';
|
|
8
|
+
import StatCard from '../components/common/StatCard';
|
|
9
|
+
import { Bus, Schedule, Booking } from '../types';
|
|
10
|
+
|
|
11
|
+
const Dashboard = () => {
|
|
12
|
+
const { user } = useAuth();
|
|
13
|
+
const [buses, setBuses] = useState<Bus[]>([]);
|
|
14
|
+
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
|
15
|
+
const [bookings, setBookings] = useState<Booking[]>([]);
|
|
16
|
+
const [userCount, setUserCount] = useState(0);
|
|
17
|
+
const [loading, setLoading] = useState(true);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const fetchData = async () => {
|
|
21
|
+
try {
|
|
22
|
+
const [b, s, bk] = await Promise.all([getBuses(), getSchedules(), getBookings()]);
|
|
23
|
+
setBuses(b);
|
|
24
|
+
setSchedules(s);
|
|
25
|
+
setBookings(bk);
|
|
26
|
+
if (user?.UserRole === 'admin') {
|
|
27
|
+
const users = await getUsers();
|
|
28
|
+
setUserCount(users.length);
|
|
29
|
+
}
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.error(err);
|
|
32
|
+
} finally {
|
|
33
|
+
setLoading(false);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
fetchData();
|
|
37
|
+
}, [user]);
|
|
38
|
+
|
|
39
|
+
const paidBookings = bookings.filter((b) => b.PaymentStatus === 'Paid');
|
|
40
|
+
const revenue = paidBookings.reduce((acc, b) => acc + (b.TicketPrice || 0), 0);
|
|
41
|
+
const activeSchedules = schedules.filter((s) => s.ScheduleStatus === 'Active');
|
|
42
|
+
const recentBookings = [...bookings]
|
|
43
|
+
.sort((a, b) => new Date(b.BookingDate).getTime() - new Date(a.BookingDate).getTime())
|
|
44
|
+
.slice(0, 5);
|
|
45
|
+
|
|
46
|
+
const quickActions = [
|
|
47
|
+
{ to: '/buses', label: 'Manage Buses', icon: '🚌', color: 'bg-blue-500' },
|
|
48
|
+
{ to: '/schedules', label: 'View Schedules', icon: '📅', color: 'bg-green-500' },
|
|
49
|
+
{ to: '/bookings', label: 'New Booking', icon: '🎫', color: 'bg-yellow-500' },
|
|
50
|
+
{ to: '/report', label: 'View Report', icon: '📊', color: 'bg-purple-500' },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
if (loading) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex items-center justify-center min-h-[60vh]">
|
|
56
|
+
<div className="text-center">
|
|
57
|
+
<div className="w-12 h-12 border-4 border-yellow-400 border-t-transparent rounded-full animate-spin mx-auto" />
|
|
58
|
+
<p className="text-gray-500 mt-3">Loading dashboard...</p>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="space-y-6">
|
|
66
|
+
{/* Header */}
|
|
67
|
+
<div className="bg-gradient-to-r from-gray-900 to-gray-800 rounded-2xl p-6 text-white shadow-lg">
|
|
68
|
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
69
|
+
<div>
|
|
70
|
+
<h1 className="text-2xl font-bold">Welcome back, {user?.UserName}! 👋</h1>
|
|
71
|
+
<p className="text-gray-400 mt-1 text-sm">Here's what's happening with Y-Bus today.</p>
|
|
72
|
+
</div>
|
|
73
|
+
<div className="flex items-center gap-2">
|
|
74
|
+
<span className="bg-yellow-400/10 text-yellow-400 border border-yellow-400/30 px-3 py-1.5 rounded-full text-xs font-medium capitalize">
|
|
75
|
+
{user?.UserRole}
|
|
76
|
+
</span>
|
|
77
|
+
<span className="text-gray-400 text-xs">
|
|
78
|
+
{new Date().toLocaleDateString('en-RW', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
|
79
|
+
</span>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{/* Stats */}
|
|
85
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
86
|
+
<StatCard title="Total Buses" value={buses.length} icon="🚌" color="bg-blue-50 text-blue-600" subtitle="Fleet registered" />
|
|
87
|
+
<StatCard title="Active Schedules" value={activeSchedules.length} icon="📅" color="bg-green-50 text-green-600" subtitle={`${schedules.length} total`} />
|
|
88
|
+
<StatCard title="Total Bookings" value={bookings.length} icon="🎫" color="bg-yellow-50 text-yellow-600" subtitle={`${paidBookings.length} paid`} />
|
|
89
|
+
{user?.UserRole === 'admin'
|
|
90
|
+
? <StatCard title="System Users" value={userCount} icon="👤" color="bg-purple-50 text-purple-600" subtitle="Registered agents" />
|
|
91
|
+
: <StatCard title="Revenue (RWF)" value={revenue.toLocaleString()} icon="💰" color="bg-emerald-50 text-emerald-600" subtitle="From paid bookings" />
|
|
92
|
+
}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{/* Quick Actions */}
|
|
96
|
+
<div>
|
|
97
|
+
<h2 className="text-lg font-semibold text-gray-800 mb-3">Quick Actions</h2>
|
|
98
|
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
99
|
+
{quickActions.map(({ to, label, icon, color }) => (
|
|
100
|
+
<Link
|
|
101
|
+
key={to}
|
|
102
|
+
to={to}
|
|
103
|
+
className="bg-white border border-gray-100 rounded-xl p-4 flex flex-col items-center gap-3 hover:shadow-md hover:border-yellow-200 transition-all group"
|
|
104
|
+
>
|
|
105
|
+
<div className={`w-12 h-12 ${color} rounded-xl flex items-center justify-center text-2xl group-hover:scale-110 transition-transform`}>
|
|
106
|
+
{icon}
|
|
107
|
+
</div>
|
|
108
|
+
<span className="text-sm font-medium text-gray-700 text-center">{label}</span>
|
|
109
|
+
</Link>
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Recent Bookings */}
|
|
115
|
+
<div className="bg-white rounded-xl border border-gray-100 shadow-sm">
|
|
116
|
+
<div className="flex items-center justify-between p-5 border-b border-gray-100">
|
|
117
|
+
<h2 className="text-lg font-semibold text-gray-800">Recent Bookings</h2>
|
|
118
|
+
<Link to="/bookings" className="text-yellow-600 hover:text-yellow-700 text-sm font-medium">
|
|
119
|
+
View all →
|
|
120
|
+
</Link>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="divide-y divide-gray-50">
|
|
123
|
+
{recentBookings.length === 0 ? (
|
|
124
|
+
<p className="text-center text-gray-400 text-sm py-8">No bookings found</p>
|
|
125
|
+
) : (
|
|
126
|
+
recentBookings.map((booking) => (
|
|
127
|
+
<div key={booking.BookingID} className="px-5 py-4 flex items-center justify-between gap-4">
|
|
128
|
+
<div className="flex items-center gap-3">
|
|
129
|
+
<div className="w-9 h-9 bg-gray-100 rounded-full flex items-center justify-center text-sm font-bold text-gray-600">
|
|
130
|
+
{booking.PassengerName?.charAt(0) || 'P'}
|
|
131
|
+
</div>
|
|
132
|
+
<div>
|
|
133
|
+
<p className="text-sm font-medium text-gray-800">{booking.PassengerName}</p>
|
|
134
|
+
<p className="text-xs text-gray-400">{booking.RouteName || 'N/A'} • Seat {booking.SeatNumber}</p>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
<div className="text-right">
|
|
138
|
+
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
|
|
139
|
+
booking.PaymentStatus === 'Paid'
|
|
140
|
+
? 'bg-green-100 text-green-700'
|
|
141
|
+
: booking.PaymentStatus === 'Cancelled'
|
|
142
|
+
? 'bg-red-100 text-red-700'
|
|
143
|
+
: 'bg-yellow-100 text-yellow-700'
|
|
144
|
+
}`}>
|
|
145
|
+
{booking.PaymentStatus}
|
|
146
|
+
</span>
|
|
147
|
+
<p className="text-xs text-gray-400 mt-1">
|
|
148
|
+
{new Date(booking.BookingDate).toLocaleDateString()}
|
|
149
|
+
</p>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
))
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{/* Schedule Overview */}
|
|
158
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
159
|
+
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
|
160
|
+
<h2 className="text-lg font-semibold text-gray-800 mb-4">Schedule Status</h2>
|
|
161
|
+
<div className="space-y-3">
|
|
162
|
+
{[
|
|
163
|
+
{ label: 'Active', value: schedules.filter(s => s.ScheduleStatus === 'Active').length, color: 'bg-green-500' },
|
|
164
|
+
{ label: 'Inactive', value: schedules.filter(s => s.ScheduleStatus === 'Inactive').length, color: 'bg-gray-400' },
|
|
165
|
+
{ label: 'Cancelled', value: schedules.filter(s => s.ScheduleStatus === 'Cancelled').length, color: 'bg-red-500' },
|
|
166
|
+
].map(({ label, value, color }) => (
|
|
167
|
+
<div key={label} className="flex items-center gap-3">
|
|
168
|
+
<div className={`w-3 h-3 ${color} rounded-full`} />
|
|
169
|
+
<span className="text-sm text-gray-600 flex-1">{label}</span>
|
|
170
|
+
<span className="font-semibold text-gray-800">{value}</span>
|
|
171
|
+
</div>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
|
177
|
+
<h2 className="text-lg font-semibold text-gray-800 mb-4">Booking Status</h2>
|
|
178
|
+
<div className="space-y-3">
|
|
179
|
+
{[
|
|
180
|
+
{ label: 'Paid', value: bookings.filter(b => b.PaymentStatus === 'Paid').length, color: 'bg-green-500' },
|
|
181
|
+
{ label: 'Pending', value: bookings.filter(b => b.PaymentStatus === 'Pending').length, color: 'bg-yellow-500' },
|
|
182
|
+
{ label: 'Cancelled', value: bookings.filter(b => b.PaymentStatus === 'Cancelled').length, color: 'bg-red-500' },
|
|
183
|
+
].map(({ label, value, color }) => (
|
|
184
|
+
<div key={label} className="flex items-center gap-3">
|
|
185
|
+
<div className={`w-3 h-3 ${color} rounded-full`} />
|
|
186
|
+
<span className="text-sm text-gray-600 flex-1">{label}</span>
|
|
187
|
+
<span className="font-semibold text-gray-800">{value}</span>
|
|
188
|
+
</div>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export default Dashboard;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useState, FormEvent } from 'react';
|
|
2
|
+
import { useNavigate } from 'react-router-dom';
|
|
3
|
+
import { useAuth } from '../context/AuthContext';
|
|
4
|
+
|
|
5
|
+
const Login = () => {
|
|
6
|
+
const { login } = useAuth();
|
|
7
|
+
const navigate = useNavigate();
|
|
8
|
+
const [form, setForm] = useState({ UserName: '', Password: '' });
|
|
9
|
+
const [error, setError] = useState('');
|
|
10
|
+
const [loading, setLoading] = useState(false);
|
|
11
|
+
|
|
12
|
+
const handleSubmit = async (e: FormEvent) => {
|
|
13
|
+
e.preventDefault();
|
|
14
|
+
setError('');
|
|
15
|
+
setLoading(true);
|
|
16
|
+
try {
|
|
17
|
+
await login(form.UserName, form.Password);
|
|
18
|
+
navigate('/dashboard');
|
|
19
|
+
} catch (err: unknown) {
|
|
20
|
+
const message = err instanceof Error ? err.message : 'Invalid credentials. Please try again.';
|
|
21
|
+
setError(message || 'Invalid credentials. Please try again.');
|
|
22
|
+
} finally {
|
|
23
|
+
setLoading(false);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 flex items-center justify-center px-4">
|
|
29
|
+
{/* Background Pattern */}
|
|
30
|
+
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
|
31
|
+
<div className="absolute -top-20 -left-20 w-96 h-96 bg-yellow-400/5 rounded-full blur-3xl" />
|
|
32
|
+
<div className="absolute -bottom-20 -right-20 w-96 h-96 bg-yellow-400/5 rounded-full blur-3xl" />
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div className="relative w-full max-w-md">
|
|
36
|
+
{/* Logo Section */}
|
|
37
|
+
<div className="text-center mb-8">
|
|
38
|
+
<div className="w-20 h-20 bg-yellow-400 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg shadow-yellow-400/30">
|
|
39
|
+
<span className="text-gray-900 font-black text-4xl">Y</span>
|
|
40
|
+
</div>
|
|
41
|
+
<h1 className="text-3xl font-bold text-white">Y-Bus System</h1>
|
|
42
|
+
<p className="text-gray-400 mt-1 text-sm">Kigali City Transport & Reservation</p>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
{/* Card */}
|
|
46
|
+
<div className="bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl p-8 shadow-2xl">
|
|
47
|
+
<h2 className="text-xl font-semibold text-white mb-2">Welcome Back</h2>
|
|
48
|
+
<p className="text-gray-400 text-sm mb-6">Sign in to your account to continue</p>
|
|
49
|
+
|
|
50
|
+
{error && (
|
|
51
|
+
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg flex items-center gap-2">
|
|
52
|
+
<span className="text-red-400 text-sm">⚠️ {error}</span>
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
57
|
+
<div>
|
|
58
|
+
<label className="block text-sm font-medium text-gray-300 mb-1.5">Username</label>
|
|
59
|
+
<input
|
|
60
|
+
type="text"
|
|
61
|
+
required
|
|
62
|
+
value={form.UserName}
|
|
63
|
+
onChange={(e) => setForm({ ...form, UserName: e.target.value })}
|
|
64
|
+
className="w-full bg-white/10 border border-white/20 text-white placeholder-gray-500 rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-yellow-400 focus:border-transparent transition-all"
|
|
65
|
+
placeholder="Enter your username"
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div>
|
|
70
|
+
<label className="block text-sm font-medium text-gray-300 mb-1.5">Password</label>
|
|
71
|
+
<input
|
|
72
|
+
type="password"
|
|
73
|
+
required
|
|
74
|
+
value={form.Password}
|
|
75
|
+
onChange={(e) => setForm({ ...form, Password: e.target.value })}
|
|
76
|
+
className="w-full bg-white/10 border border-white/20 text-white placeholder-gray-500 rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-yellow-400 focus:border-transparent transition-all"
|
|
77
|
+
placeholder="Enter your password"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<button
|
|
82
|
+
type="submit"
|
|
83
|
+
disabled={loading}
|
|
84
|
+
className="w-full bg-yellow-400 hover:bg-yellow-300 disabled:bg-yellow-400/50 text-gray-900 font-semibold py-3 rounded-lg text-sm transition-all flex items-center justify-center gap-2 shadow-lg shadow-yellow-400/20"
|
|
85
|
+
>
|
|
86
|
+
{loading ? (
|
|
87
|
+
<>
|
|
88
|
+
<div className="w-4 h-4 border-2 border-gray-900 border-t-transparent rounded-full animate-spin" />
|
|
89
|
+
Signing in...
|
|
90
|
+
</>
|
|
91
|
+
) : (
|
|
92
|
+
'Sign In'
|
|
93
|
+
)}
|
|
94
|
+
</button>
|
|
95
|
+
</form>
|
|
96
|
+
|
|
97
|
+
<div className="mt-6 pt-6 border-t border-white/10">
|
|
98
|
+
<p className="text-center text-xs text-gray-500">
|
|
99
|
+
Default: <span className="text-yellow-400">admin</span> / <span className="text-yellow-400">admin123</span>
|
|
100
|
+
</p>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<p className="text-center text-gray-600 text-xs mt-4">
|
|
105
|
+
© 2026 Y Company — Nyarugenge District, Kigali City
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export default Login;
|