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.
- package/.gitignore +6 -0
- package/README.md +0 -0
- package/backend-project/config/db.js +13 -0
- package/backend-project/controllers/authController.js +63 -0
- package/backend-project/controllers/productController.js +113 -0
- package/backend-project/controllers/reportController.js +93 -0
- package/backend-project/controllers/saleController.js +91 -0
- package/backend-project/controllers/stockController.js +126 -0
- package/backend-project/middleware/auth.js +8 -0
- package/backend-project/models/Product.js +40 -0
- package/backend-project/models/Sale.js +38 -0
- package/backend-project/models/StockStatus.js +35 -0
- package/backend-project/models/User.js +24 -0
- package/backend-project/package.json +24 -0
- package/backend-project/routes/authRoutes.js +9 -0
- package/backend-project/routes/productRoutes.js +9 -0
- package/backend-project/routes/reportRoutes.js +14 -0
- package/backend-project/routes/saleRoutes.js +8 -0
- package/backend-project/routes/stockRoutes.js +18 -0
- package/backend-project/server.js +55 -0
- package/backend-project/utils/seed.js +44 -0
- package/frontend-project/index.html +13 -0
- package/frontend-project/package.json +32 -0
- package/frontend-project/postcss.config.js +6 -0
- package/frontend-project/src/App.jsx +7 -0
- package/frontend-project/src/components/LoadingSpinner.jsx +8 -0
- package/frontend-project/src/components/Pagination.jsx +74 -0
- package/frontend-project/src/components/ProtectedRoute.jsx +22 -0
- package/frontend-project/src/components/SearchBar.jsx +16 -0
- package/frontend-project/src/components/StatCard.jsx +20 -0
- package/frontend-project/src/context/AuthContext.jsx +60 -0
- package/frontend-project/src/index.css +3 -0
- package/frontend-project/src/layouts/MainLayout.jsx +12 -0
- package/frontend-project/src/layouts/Sidebar.jsx +96 -0
- package/frontend-project/src/main.jsx +16 -0
- package/frontend-project/src/pages/Dashboard.jsx +184 -0
- package/frontend-project/src/pages/Login.jsx +111 -0
- package/frontend-project/src/pages/Products.jsx +192 -0
- package/frontend-project/src/pages/Reports.jsx +320 -0
- package/frontend-project/src/pages/Sales.jsx +150 -0
- package/frontend-project/src/pages/StockStatus.jsx +306 -0
- package/frontend-project/src/routes/AppRoutes.jsx +66 -0
- package/frontend-project/src/services/api.js +52 -0
- package/frontend-project/src/services/authService.js +5 -0
- package/frontend-project/src/services/productService.js +4 -0
- package/frontend-project/src/services/reportService.js +5 -0
- package/frontend-project/src/services/saleService.js +3 -0
- package/frontend-project/src/services/stockService.js +7 -0
- package/frontend-project/tailwind.config.js +8 -0
- package/frontend-project/vite.config.js +9 -0
- 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
|
+
}
|