react-node-app 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.
Files changed (51) hide show
  1. package/.gitignore +6 -0
  2. package/README.md +0 -0
  3. package/backend-project/config/db.js +13 -0
  4. package/backend-project/controllers/authController.js +63 -0
  5. package/backend-project/controllers/productController.js +113 -0
  6. package/backend-project/controllers/reportController.js +93 -0
  7. package/backend-project/controllers/saleController.js +91 -0
  8. package/backend-project/controllers/stockController.js +126 -0
  9. package/backend-project/middleware/auth.js +8 -0
  10. package/backend-project/models/Product.js +40 -0
  11. package/backend-project/models/Sale.js +38 -0
  12. package/backend-project/models/StockStatus.js +35 -0
  13. package/backend-project/models/User.js +24 -0
  14. package/backend-project/package.json +24 -0
  15. package/backend-project/routes/authRoutes.js +9 -0
  16. package/backend-project/routes/productRoutes.js +9 -0
  17. package/backend-project/routes/reportRoutes.js +14 -0
  18. package/backend-project/routes/saleRoutes.js +8 -0
  19. package/backend-project/routes/stockRoutes.js +18 -0
  20. package/backend-project/server.js +55 -0
  21. package/backend-project/utils/seed.js +44 -0
  22. package/frontend-project/index.html +13 -0
  23. package/frontend-project/package.json +32 -0
  24. package/frontend-project/postcss.config.js +6 -0
  25. package/frontend-project/src/App.jsx +7 -0
  26. package/frontend-project/src/components/LoadingSpinner.jsx +8 -0
  27. package/frontend-project/src/components/Pagination.jsx +74 -0
  28. package/frontend-project/src/components/ProtectedRoute.jsx +22 -0
  29. package/frontend-project/src/components/SearchBar.jsx +16 -0
  30. package/frontend-project/src/components/StatCard.jsx +20 -0
  31. package/frontend-project/src/context/AuthContext.jsx +60 -0
  32. package/frontend-project/src/index.css +3 -0
  33. package/frontend-project/src/layouts/MainLayout.jsx +12 -0
  34. package/frontend-project/src/layouts/Sidebar.jsx +96 -0
  35. package/frontend-project/src/main.jsx +16 -0
  36. package/frontend-project/src/pages/Dashboard.jsx +184 -0
  37. package/frontend-project/src/pages/Login.jsx +111 -0
  38. package/frontend-project/src/pages/Products.jsx +192 -0
  39. package/frontend-project/src/pages/Reports.jsx +320 -0
  40. package/frontend-project/src/pages/Sales.jsx +150 -0
  41. package/frontend-project/src/pages/StockStatus.jsx +306 -0
  42. package/frontend-project/src/routes/AppRoutes.jsx +66 -0
  43. package/frontend-project/src/services/api.js +52 -0
  44. package/frontend-project/src/services/authService.js +5 -0
  45. package/frontend-project/src/services/productService.js +4 -0
  46. package/frontend-project/src/services/reportService.js +5 -0
  47. package/frontend-project/src/services/saleService.js +3 -0
  48. package/frontend-project/src/services/stockService.js +7 -0
  49. package/frontend-project/tailwind.config.js +8 -0
  50. package/frontend-project/vite.config.js +9 -0
  51. package/package.json +59 -0
@@ -0,0 +1,320 @@
1
+ import { useState } from 'react';
2
+ import Swal from 'sweetalert2';
3
+ import { getDailySalesReport, getStockStatusReport } from '../services/reportService';
4
+ import LoadingSpinner from '../components/LoadingSpinner';
5
+
6
+ export default function Reports() {
7
+ const [activeTab, setActiveTab] = useState('sales');
8
+
9
+ return (
10
+ <div className="space-y-6">
11
+ <div>
12
+ <h1 className="text-2xl font-bold text-slate-800">Reports</h1>
13
+ <p className="text-slate-500 text-sm mt-1">View sales and stock reports</p>
14
+ </div>
15
+
16
+ <div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
17
+ <button
18
+ onClick={() => setActiveTab('sales')}
19
+ className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
20
+ activeTab === 'sales' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'
21
+ }`}
22
+ >
23
+ Daily Sales Report
24
+ </button>
25
+ <button
26
+ onClick={() => setActiveTab('stock')}
27
+ className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
28
+ activeTab === 'stock' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'
29
+ }`}
30
+ >
31
+ Stock Status Report
32
+ </button>
33
+ </div>
34
+
35
+ {activeTab === 'sales' ? <DailySalesReport /> : <StockStatusReport />}
36
+ </div>
37
+ );
38
+ }
39
+
40
+ function DailySalesReport() {
41
+ const [date, setDate] = useState('');
42
+ const [startDate, setStartDate] = useState('');
43
+ const [endDate, setEndDate] = useState('');
44
+ const [data, setData] = useState(null);
45
+ const [loading, setLoading] = useState(false);
46
+ const [fetched, setFetched] = useState(false);
47
+
48
+ const handleSearch = async (e) => {
49
+ e.preventDefault();
50
+ setLoading(true);
51
+ setFetched(true);
52
+ try {
53
+ const params = {};
54
+ if (date) {
55
+ params.date = date;
56
+ } else if (startDate && endDate) {
57
+ params.startDate = startDate;
58
+ params.endDate = endDate;
59
+ }
60
+ const res = await getDailySalesReport(params);
61
+ setData(res.data);
62
+ } catch {
63
+ // handled by interceptor
64
+ } finally {
65
+ setLoading(false);
66
+ }
67
+ };
68
+
69
+ const handleExport = () => {
70
+ if (!data || data.sales.length === 0) {
71
+ Swal.fire({ icon: 'info', title: 'No data', text: 'Nothing to export', toast: true, position: 'top-end', timer: 2000, showConfirmButton: false });
72
+ return;
73
+ }
74
+ const headers = ['Product Name', 'Quantity Sold', 'Unit Price', 'Total Amount', 'Sales Date'];
75
+ const rows = data.sales.map((s) => [
76
+ s.productId?.productName || 'N/A',
77
+ s.soldQuantity,
78
+ s.soldUnitPrice,
79
+ s.soldTotalPrice,
80
+ new Date(s.salesDate).toLocaleDateString(),
81
+ ]);
82
+ const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
83
+ const blob = new Blob([csv], { type: 'text/csv' });
84
+ const url = URL.createObjectURL(blob);
85
+ const a = document.createElement('a');
86
+ a.href = url;
87
+ a.download = `daily-sales-report-${date || 'all'}.csv`;
88
+ a.click();
89
+ URL.revokeObjectURL(url);
90
+ };
91
+
92
+ const formatRwf = (amount) =>
93
+ new Intl.NumberFormat('en-US', { style: 'currency', currency: 'RWF', minimumFractionDigits: 0 }).format(amount);
94
+
95
+ return (
96
+ <div className="space-y-4">
97
+ <div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
98
+ <form onSubmit={handleSearch} className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
99
+ <div>
100
+ <label className="block text-sm font-medium text-slate-700 mb-1">Specific Date</label>
101
+ <input
102
+ type="date"
103
+ value={date}
104
+ onChange={(e) => { setDate(e.target.value); setStartDate(''); setEndDate(''); }}
105
+ className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-800 focus:border-transparent"
106
+ />
107
+ </div>
108
+ <div>
109
+ <label className="block text-sm font-medium text-slate-700 mb-1">Start Date</label>
110
+ <input
111
+ type="date"
112
+ value={startDate}
113
+ onChange={(e) => { setStartDate(e.target.value); setDate(''); }}
114
+ className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-800 focus:border-transparent"
115
+ disabled={!!date}
116
+ />
117
+ </div>
118
+ <div>
119
+ <label className="block text-sm font-medium text-slate-700 mb-1">End Date</label>
120
+ <input
121
+ type="date"
122
+ value={endDate}
123
+ onChange={(e) => { setEndDate(e.target.value); setDate(''); }}
124
+ className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-800 focus:border-transparent"
125
+ disabled={!!date}
126
+ />
127
+ </div>
128
+ <div className="flex gap-2">
129
+ <button
130
+ type="submit"
131
+ disabled={loading}
132
+ className="px-6 py-2.5 bg-slate-800 text-white rounded-lg text-sm font-medium hover:bg-slate-700 transition-colors disabled:opacity-50"
133
+ >
134
+ {loading ? 'Searching...' : 'Search'}
135
+ </button>
136
+ {data && (
137
+ <button
138
+ type="button"
139
+ onClick={handleExport}
140
+ className="px-4 py-2.5 border border-gray-200 text-slate-600 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
141
+ >
142
+ Export CSV
143
+ </button>
144
+ )}
145
+ </div>
146
+ </form>
147
+ </div>
148
+
149
+ {loading && <LoadingSpinner message="Generating report..." />}
150
+
151
+ {fetched && !loading && data && (
152
+ <div className="bg-white rounded-xl border border-gray-200 shadow-sm">
153
+ <div className="p-4 border-b border-gray-200 flex items-center justify-between">
154
+ <h3 className="text-sm font-semibold text-slate-700">
155
+ Sales Report {date ? `- ${new Date(date).toLocaleDateString()}` : startDate && endDate ? `- ${new Date(startDate).toLocaleDateString()} to ${new Date(endDate).toLocaleDateString()}` : ''}
156
+ </h3>
157
+ <p className="text-sm font-medium text-slate-800">
158
+ Total Revenue: {formatRwf(data.totalRevenue || 0)}
159
+ </p>
160
+ </div>
161
+ <div className="overflow-x-auto">
162
+ <table className="w-full text-sm">
163
+ <thead>
164
+ <tr className="border-b border-gray-200 bg-slate-50">
165
+ <th className="text-left p-4 text-slate-500 font-medium">Product</th>
166
+ <th className="text-left p-4 text-slate-500 font-medium">Qty Sold</th>
167
+ <th className="text-left p-4 text-slate-500 font-medium">Unit Price</th>
168
+ <th className="text-left p-4 text-slate-500 font-medium">Total Amount</th>
169
+ <th className="text-left p-4 text-slate-500 font-medium">Sale Date</th>
170
+ </tr>
171
+ </thead>
172
+ <tbody>
173
+ {data.sales.length > 0 ? (
174
+ data.sales.map((sale) => (
175
+ <tr key={sale._id} className="border-b border-gray-100 hover:bg-slate-50">
176
+ <td className="p-4 font-medium text-slate-700">{sale.productId?.productName || 'N/A'}</td>
177
+ <td className="p-4 text-slate-600">{sale.soldQuantity}</td>
178
+ <td className="p-4 text-slate-600">{formatRwf(sale.soldUnitPrice)}</td>
179
+ <td className="p-4 text-slate-600">{formatRwf(sale.soldTotalPrice)}</td>
180
+ <td className="p-4 text-slate-500">{new Date(sale.salesDate).toLocaleDateString()}</td>
181
+ </tr>
182
+ ))
183
+ ) : (
184
+ <tr>
185
+ <td colSpan={5} className="p-8 text-center text-slate-400">No sales found for this period</td>
186
+ </tr>
187
+ )}
188
+ </tbody>
189
+ </table>
190
+ </div>
191
+ </div>
192
+ )}
193
+ </div>
194
+ );
195
+ }
196
+
197
+ function StockStatusReport() {
198
+ const [date, setDate] = useState('');
199
+ const [data, setData] = useState(null);
200
+ const [loading, setLoading] = useState(false);
201
+ const [fetched, setFetched] = useState(false);
202
+
203
+ const handleSearch = async (e) => {
204
+ e.preventDefault();
205
+ setLoading(true);
206
+ setFetched(true);
207
+ try {
208
+ const params = {};
209
+ if (date) params.date = date;
210
+ const res = await getStockStatusReport(params);
211
+ setData(res.data);
212
+ } catch {
213
+ // handled by interceptor
214
+ } finally {
215
+ setLoading(false);
216
+ }
217
+ };
218
+
219
+ const handleExport = () => {
220
+ if (!data || data.stockStatuses.length === 0) {
221
+ Swal.fire({ icon: 'info', title: 'No data', text: 'Nothing to export', toast: true, position: 'top-end', timer: 2000, showConfirmButton: false });
222
+ return;
223
+ }
224
+ const headers = ['Product Name', 'Stored Quantity', 'Sold Quantity', 'Remaining Quantity'];
225
+ const rows = data.stockStatuses.map((s) => [
226
+ s.productId?.productName || 'N/A',
227
+ s.availableQuantity,
228
+ s.soldQuantity,
229
+ s.remainingQuantity,
230
+ ]);
231
+ const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
232
+ const blob = new Blob([csv], { type: 'text/csv' });
233
+ const url = URL.createObjectURL(blob);
234
+ const a = document.createElement('a');
235
+ a.href = url;
236
+ a.download = `stock-status-report-${date || 'all'}.csv`;
237
+ a.click();
238
+ URL.revokeObjectURL(url);
239
+ };
240
+
241
+ return (
242
+ <div className="space-y-4">
243
+ <div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
244
+ <form onSubmit={handleSearch} className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
245
+ <div>
246
+ <label className="block text-sm font-medium text-slate-700 mb-1">Filter by Date</label>
247
+ <input
248
+ type="date"
249
+ value={date}
250
+ onChange={(e) => setDate(e.target.value)}
251
+ className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-800 focus:border-transparent"
252
+ />
253
+ </div>
254
+ <div className="flex gap-2">
255
+ <button
256
+ type="submit"
257
+ disabled={loading}
258
+ className="px-6 py-2.5 bg-slate-800 text-white rounded-lg text-sm font-medium hover:bg-slate-700 transition-colors disabled:opacity-50"
259
+ >
260
+ {loading ? 'Searching...' : 'Search'}
261
+ </button>
262
+ {data && (
263
+ <button
264
+ type="button"
265
+ onClick={handleExport}
266
+ className="px-4 py-2.5 border border-gray-200 text-slate-600 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
267
+ >
268
+ Export CSV
269
+ </button>
270
+ )}
271
+ </div>
272
+ </form>
273
+ </div>
274
+
275
+ {loading && <LoadingSpinner message="Generating report..." />}
276
+
277
+ {fetched && !loading && data && (
278
+ <div className="bg-white rounded-xl border border-gray-200 shadow-sm">
279
+ <div className="p-4 border-b border-gray-200">
280
+ <h3 className="text-sm font-semibold text-slate-700">
281
+ Stock Status Report {date ? `- ${new Date(date).toLocaleDateString()}` : ''}
282
+ </h3>
283
+ </div>
284
+ <div className="overflow-x-auto">
285
+ <table className="w-full text-sm">
286
+ <thead>
287
+ <tr className="border-b border-gray-200 bg-slate-50">
288
+ <th className="text-left p-4 text-slate-500 font-medium">Product</th>
289
+ <th className="text-left p-4 text-slate-500 font-medium">Stored Qty</th>
290
+ <th className="text-left p-4 text-slate-500 font-medium">Sold Qty</th>
291
+ <th className="text-left p-4 text-slate-500 font-medium">Remaining</th>
292
+ </tr>
293
+ </thead>
294
+ <tbody>
295
+ {data.stockStatuses.length > 0 ? (
296
+ data.stockStatuses.map((item) => (
297
+ <tr key={item._id} className="border-b border-gray-100 hover:bg-slate-50">
298
+ <td className="p-4 font-medium text-slate-700">{item.productId?.productName || 'N/A'}</td>
299
+ <td className="p-4 text-slate-600">{item.availableQuantity}</td>
300
+ <td className="p-4 text-slate-600">{item.soldQuantity}</td>
301
+ <td className="p-4">
302
+ <span className={`font-medium ${item.remainingQuantity <= 0 ? 'text-red-600' : item.remainingQuantity < 10 ? 'text-amber-600' : 'text-green-600'}`}>
303
+ {item.remainingQuantity}
304
+ </span>
305
+ </td>
306
+ </tr>
307
+ ))
308
+ ) : (
309
+ <tr>
310
+ <td colSpan={4} className="p-8 text-center text-slate-400">No stock status records found</td>
311
+ </tr>
312
+ )}
313
+ </tbody>
314
+ </table>
315
+ </div>
316
+ </div>
317
+ )}
318
+ </div>
319
+ );
320
+ }
@@ -0,0 +1,150 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useForm } from 'react-hook-form';
3
+ import Swal from 'sweetalert2';
4
+ import { getProducts } from '../services/productService';
5
+ import { createSale } from '../services/saleService';
6
+ import LoadingSpinner from '../components/LoadingSpinner';
7
+
8
+ export default function Sales() {
9
+ const [products, setProducts] = useState([]);
10
+ const [loading, setLoading] = useState(true);
11
+ const [submitting, setSubmitting] = useState(false);
12
+
13
+ const {
14
+ register,
15
+ handleSubmit,
16
+ reset,
17
+ watch,
18
+ formState: { errors },
19
+ } = useForm();
20
+
21
+ const productId = watch('productId');
22
+ const soldQuantity = watch('soldQuantity');
23
+ const soldUnitPrice = watch('soldUnitPrice');
24
+
25
+ const selectedProduct = products.find((p) => p._id === productId);
26
+ const soldTotalPrice = (parseFloat(soldQuantity || 0) * parseFloat(soldUnitPrice || 0)).toFixed(2);
27
+
28
+ const fetchProducts = async () => {
29
+ try {
30
+ const res = await getProducts({ limit: 100 });
31
+ setProducts(res.data.products);
32
+ } catch {
33
+ }
34
+ };
35
+
36
+ useEffect(() => {
37
+ const init = async () => {
38
+ setLoading(true);
39
+ await fetchProducts();
40
+ setLoading(false);
41
+ };
42
+ init();
43
+ }, []);
44
+
45
+ const onSubmit = async (data) => {
46
+ setSubmitting(true);
47
+ try {
48
+ await createSale({
49
+ ...data,
50
+ soldQuantity: parseFloat(data.soldQuantity),
51
+ soldUnitPrice: parseFloat(data.soldUnitPrice),
52
+ });
53
+ Swal.fire({ icon: 'success', title: 'Sale Recorded!', text: 'Sale created successfully', timer: 1500, showConfirmButton: false, toast: true, position: 'top-end' });
54
+ reset();
55
+ fetchProducts();
56
+ } catch {
57
+ } finally {
58
+ setSubmitting(false);
59
+ }
60
+ };
61
+
62
+ if (loading) return <LoadingSpinner message="Loading sales..." />;
63
+
64
+ return (
65
+ <div className="space-y-6">
66
+ <div>
67
+ <h1 className="text-2xl font-bold text-slate-800">Sales</h1>
68
+ <p className="text-slate-500 text-sm mt-1">Record sales transactions</p>
69
+ </div>
70
+
71
+ <div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
72
+ <h3 className="text-sm font-semibold text-slate-700 mb-4">Record New Sale</h3>
73
+ <form onSubmit={handleSubmit(onSubmit)} className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
74
+ <div>
75
+ <label className="block text-sm font-medium text-slate-700 mb-1">Product</label>
76
+ <select
77
+ {...register('productId', { required: 'Product is required' })}
78
+ className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-800 focus:border-transparent bg-white"
79
+ >
80
+ <option value="">Select product</option>
81
+ {products.map((p) => (
82
+ <option key={p._id} value={p._id}>
83
+ {p.productName} (Stock: {p.quantity})
84
+ </option>
85
+ ))}
86
+ </select>
87
+ {errors.productId && <p className="text-red-500 text-xs mt-1">{errors.productId.message}</p>}
88
+ {selectedProduct && !errors.productId && (
89
+ <p className="text-xs text-slate-400 mt-1">Available stock: {selectedProduct.quantity}</p>
90
+ )}
91
+ </div>
92
+ <div>
93
+ <label className="block text-sm font-medium text-slate-700 mb-1">Sold Quantity</label>
94
+ <input
95
+ type="number"
96
+ step="1"
97
+ {...register('soldQuantity', {
98
+ required: 'Quantity is required',
99
+ min: { value: 1, message: 'Quantity must be at least 1' },
100
+ validate: (v) =>
101
+ !selectedProduct || parseFloat(v) <= selectedProduct.quantity || 'Insufficient stock',
102
+ })}
103
+ className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-800 focus:border-transparent"
104
+ />
105
+ {errors.soldQuantity && <p className="text-red-500 text-xs mt-1">{errors.soldQuantity.message}</p>}
106
+ </div>
107
+ <div>
108
+ <label className="block text-sm font-medium text-slate-700 mb-1">Sold Unit Price (RWF)</label>
109
+ <input
110
+ type="number"
111
+ step="0.01"
112
+ {...register('soldUnitPrice', {
113
+ required: 'Unit price is required',
114
+ min: { value: 0.01, message: 'Price must be > 0' },
115
+ })}
116
+ className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-800 focus:border-transparent"
117
+ />
118
+ {errors.soldUnitPrice && <p className="text-red-500 text-xs mt-1">{errors.soldUnitPrice.message}</p>}
119
+ </div>
120
+ <div>
121
+ <label className="block text-sm font-medium text-slate-700 mb-1">Sales Date</label>
122
+ <input
123
+ type="date"
124
+ {...register('salesDate', { required: 'Sales date is required' })}
125
+ className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-800 focus:border-transparent"
126
+ />
127
+ {errors.salesDate && <p className="text-red-500 text-xs mt-1">{errors.salesDate.message}</p>}
128
+ </div>
129
+ <div>
130
+ <label className="block text-sm font-medium text-slate-700 mb-1">Sold Total Price</label>
131
+ <input
132
+ value={soldTotalPrice}
133
+ readOnly
134
+ className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm bg-slate-50 text-slate-500"
135
+ />
136
+ </div>
137
+ <div className="flex items-end">
138
+ <button
139
+ type="submit"
140
+ disabled={submitting}
141
+ className="px-6 py-2.5 bg-slate-800 text-white rounded-lg text-sm font-medium hover:bg-slate-700 transition-colors disabled:opacity-50"
142
+ >
143
+ {submitting ? 'Recording...' : 'Record Sale'}
144
+ </button>
145
+ </div>
146
+ </form>
147
+ </div>
148
+ </div>
149
+ );
150
+ }