EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
ProductForm.jsx
Go to the documentation of this file.
1import { useState, useEffect } from 'react';
2import { useNavigate, useParams } from 'react-router-dom';
3import MainLayout from '../components/Layout/MainLayout';
4// import removed: TenantSelector
5import './ProductForm.css';
6import { apiFetch } from '../lib/api';
7
8export default function ProductForm() {
9 const navigate = useNavigate();
10 const { id } = useParams();
11 const [selectedTenantId, setSelectedTenantId] = useState('');
12 const [isRootAdmin, setIsRootAdmin] = useState(false);
13 const [form, setForm] = useState({
14 name: '',
15 description: '',
16 supplier: '',
17 is_service: false,
18 divisible: false,
19 is_stock: false,
20 stock_quantity: 0,
21 min_stock: 0,
22 price_retail: 0,
23 price_ex_tax: 0,
24 upc: '',
25 ean: '',
26 supplier_code: ''
27 });
28 const [loading, setLoading] = useState(false);
29 const [loadingData, setLoadingData] = useState(false);
30 const [error, setError] = useState(null);
31
32 useEffect(() => {
33 // Check if user is root admin
34 const token = localStorage.getItem('token');
35 if (token) {
36 try {
37 const payload = token.split('.')[1];
38 const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
39 const isRoot = decoded.subdomain === 'admin' || decoded.is_msp === true;
40 setIsRootAdmin(isRoot);
41 } catch { }
42 }
43 }, []);
44
45 useEffect(() => {
46 if (id) fetchProduct();
47 }, [id, selectedTenantId]);
48
49 const getTenantHeaders = async (baseHeaders = {}) => {
50 const headers = { ...baseHeaders };
51 if (selectedTenantId) {
52 const token = localStorage.getItem('token');
53 const tenantRes = await apiFetch('/tenants', {
54 headers: {
55 'Authorization': `Bearer ${token}`,
56 'X-Tenant-Subdomain': 'admin'
57 }
58 });
59 if (tenantRes.ok) {
60 const data = await tenantRes.json();
61 const tenants = Array.isArray(data) ? data : (data.tenants || []);
62 const tenant = tenants.find(t => t.tenant_id === parseInt(selectedTenantId));
63 if (tenant) {
64 headers['X-Tenant-Subdomain'] = tenant.subdomain;
65 }
66 }
67 }
68 return headers;
69 };
70
71 const fetchProduct = async () => {
72 setLoadingData(true);
73 try {
74 const token = localStorage.getItem('token');
75 const headers = await getTenantHeaders({ Authorization: `Bearer ${token}` });
76 const res = await apiFetch(`/products/${id}`, { headers });
77 if (!res.ok) throw new Error('Failed to load');
78 const data = await res.json();
79
80 // Auto-select the tenant if the product belongs to a different tenant
81 if (data.tenant_id && !selectedTenantId) {
82 setSelectedTenantId(String(data.tenant_id));
83 }
84
85 setForm({
86 name: data.name || '',
87 description: data.description || '',
88 supplier: data.supplier || '',
89 is_service: data.is_service,
90 divisible: data.divisible,
91 is_stock: data.is_stock,
92 stock_quantity: data.stock_quantity || 0,
93 min_stock: data.min_stock || 0,
94 price_retail: data.price_retail || 0,
95 price_ex_tax: data.price_ex_tax || 0,
96 upc: data.upc || '',
97 ean: data.ean || '',
98 supplier_code: data.supplier_code || ''
99 });
100 } catch (err) {
101 setError(err.message || 'Error');
102 } finally { setLoadingData(false); }
103 };
104
105 const handleSave = async () => {
106 setLoading(true);
107 try {
108 const token = localStorage.getItem('token');
109 const headers = await getTenantHeaders({
110 'Content-Type': 'application/json',
111 Authorization: `Bearer ${token}`
112 });
113 const url = id ? `/products/${id}` : '/products';
114 const method = id ? 'PUT' : 'POST';
115 const res = await apiFetch(url, { method, headers, body: JSON.stringify(form) });
116 if (!res.ok) throw new Error('Save failed');
117 navigate('/products');
118 } catch (err) {
119 setError(err.message || 'Save error');
120 } finally { setLoading(false); }
121 };
122
123 const handleDelete = async () => {
124 if (!confirm(`Are you sure you want to delete "${form.name}"? This action cannot be undone.`)) {
125 return;
126 }
127
128 setLoading(true);
129 try {
130 const res = await apiFetch(`/products/${id}`, {
131 method: 'DELETE',
132 credentials: 'include'
133 });
134
135 if (res.status === 204) {
136 // Success - navigate back to products list
137 navigate('/products');
138 } else {
139 // Try to parse JSON error message
140 try {
141 const errorData = await res.json();
142 setError(errorData.message || errorData.error || 'Failed to delete product');
143 } catch {
144 const errorText = await res.text();
145 setError(errorText || 'Failed to delete product');
146 }
147 }
148 } catch (err) {
149 console.error('[ProductForm] Delete error:', err);
150 setError('Failed to delete product: ' + (err.message || err));
151 } finally {
152 setLoading(false);
153 }
154 };
155
156 return (
157 <MainLayout>
158 <div className="page-content">
159 <div className="page-header">
160 <h2>{id ? 'Edit Product' : 'New Product'}</h2>
161 <div className="header-actions">
162 <button className="btn btn-secondary" onClick={() => navigate('/products')}>
163 <span className="material-symbols-outlined">arrow_back</span>
164 Back
165 </button>
166 {id && isRootAdmin && (
167 <button
168 className="btn btn-danger"
169 onClick={handleDelete}
170 disabled={loading}
171 style={{ marginLeft: '8px' }}
172 >
173 <span className="material-symbols-outlined">delete</span>
174 Delete
175 </button>
176 )}
177 </div>
178 </div>
179
180 {error && <div className="alert alert-error">{error}</div>}
181
182
183 {loadingData ? (
184 <div className="loading-state">
185 <span className="material-symbols-outlined spinning">progress_activity</span>
186 <p>Loading product...</p>
187 </div>
188 ) : (
189 <div className="product-form-container">
190 <div className="form-card">
191 <div className="card-header">
192 <h3>
193 <span className="material-symbols-outlined">inventory_2</span>
194 Basic Information
195 </h3>
196 </div>
197 <div className="card-body">
198 <div className="form-group">
199 <label htmlFor="name">Product Name *</label>
200 <input
201 id="name"
202 type="text"
203 value={form.name}
204 onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
205 placeholder="Enter product name"
206 required
207 />
208 </div>
209
210 <div className="form-group">
211 <label htmlFor="description">Description</label>
212 <textarea
213 id="description"
214 value={form.description}
215 onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
216 placeholder="Enter product description"
217 rows="3"
218 />
219 </div>
220
221 <div className="form-group">
222 <label htmlFor="supplier">Supplier</label>
223 <input
224 id="supplier"
225 type="text"
226 value={form.supplier}
227 onChange={e => setForm(f => ({ ...f, supplier: e.target.value }))}
228 placeholder="Enter supplier name"
229 />
230 </div>
231
232 <div className="form-group">
233 <label htmlFor="type">Product Type</label>
234 <select
235 id="type"
236 value={form.is_service ? 'service' : 'product'}
237 onChange={e => setForm(f => ({ ...f, is_service: e.target.value === 'service' }))}
238 >
239 <option value="product">Physical Product</option>
240 <option value="service">Service</option>
241 </select>
242 </div>
243 </div>
244 </div>
245
246 <div className="form-card">
247 <div className="card-header">
248 <h3>
249 <span className="material-symbols-outlined">barcode</span>
250 Product Codes
251 </h3>
252 </div>
253 <div className="card-body">
254 <div className="form-row">
255 <div className="form-group">
256 <label htmlFor="upc">UPC</label>
257 <input
258 id="upc"
259 type="text"
260 value={form.upc}
261 onChange={e => setForm(f => ({ ...f, upc: e.target.value }))}
262 placeholder="Universal Product Code"
263 />
264 </div>
265
266 <div className="form-group">
267 <label htmlFor="ean">EAN</label>
268 <input
269 id="ean"
270 type="text"
271 value={form.ean}
272 onChange={e => setForm(f => ({ ...f, ean: e.target.value }))}
273 placeholder="European Article Number"
274 />
275 </div>
276 </div>
277
278 <div className="form-group">
279 <label htmlFor="supplier_code">Supplier Code</label>
280 <input
281 id="supplier_code"
282 type="text"
283 value={form.supplier_code}
284 onChange={e => setForm(f => ({ ...f, supplier_code: e.target.value }))}
285 placeholder="Supplier's product code"
286 />
287 </div>
288 </div>
289 </div>
290
291 <div className="form-card">
292 <div className="card-header">
293 <h3>
294 <span className="material-symbols-outlined">warehouse</span>
295 Inventory & Stock
296 </h3>
297 </div>
298 <div className="card-body">
299 <div className="form-group checkbox-group">
300 <label className="checkbox-label">
301 <input
302 type="checkbox"
303 checked={form.divisible}
304 onChange={e => setForm(f => ({ ...f, divisible: e.target.checked }))}
305 />
306 <span>Divisible (can be sold in fractions)</span>
307 </label>
308 </div>
309
310 <div className="form-group checkbox-group">
311 <label className="checkbox-label">
312 <input
313 type="checkbox"
314 checked={form.is_stock}
315 onChange={e => setForm(f => ({ ...f, is_stock: e.target.checked }))}
316 />
317 <span>Track inventory for this product</span>
318 </label>
319 </div>
320
321 {form.is_stock && (
322 <div className="form-row">
323 <div className="form-group">
324 <label htmlFor="stock_quantity">Current Stock</label>
325 <input
326 id="stock_quantity"
327 type="number"
328 value={form.stock_quantity}
329 onChange={e => setForm(f => ({ ...f, stock_quantity: parseInt(e.target.value || 0, 10) }))}
330 min="0"
331 />
332 </div>
333
334 <div className="form-group">
335 <label htmlFor="min_stock">Minimum Stock Level</label>
336 <input
337 id="min_stock"
338 type="number"
339 value={form.min_stock}
340 onChange={e => setForm(f => ({ ...f, min_stock: parseInt(e.target.value || 0, 10) }))}
341 min="0"
342 />
343 </div>
344 </div>
345 )}
346 </div>
347 </div>
348
349 <div className="form-card">
350 <div className="card-header">
351 <h3>
352 <span className="material-symbols-outlined">payments</span>
353 Pricing
354 </h3>
355 </div>
356 <div className="card-body">
357 <div className="form-row">
358 <div className="form-group">
359 <label htmlFor="price_retail">Retail Price</label>
360 <div className="input-with-prefix">
361 <span className="input-prefix">$</span>
362 <input
363 id="price_retail"
364 type="number"
365 step="0.01"
366 value={form.price_retail}
367 onChange={e => setForm(f => ({ ...f, price_retail: parseFloat(e.target.value || 0) }))}
368 min="0"
369 placeholder="0.00"
370 />
371 </div>
372 </div>
373
374 <div className="form-group">
375 <label htmlFor="price_ex_tax">Price (Ex Tax)</label>
376 <div className="input-with-prefix">
377 <span className="input-prefix">$</span>
378 <input
379 id="price_ex_tax"
380 type="number"
381 step="0.01"
382 value={form.price_ex_tax}
383 onChange={e => setForm(f => ({ ...f, price_ex_tax: parseFloat(e.target.value || 0) }))}
384 min="0"
385 placeholder="0.00"
386 />
387 </div>
388 </div>
389 </div>
390 </div>
391 </div>
392
393 <div className="form-actions">
394 <button className="btn btn-primary" onClick={handleSave} disabled={loading || !form.name}>
395 {loading ? (
396 <>
397 <span className="material-symbols-outlined spinning">progress_activity</span>
398 Saving...
399 </>
400 ) : (
401 <>
402 <span className="material-symbols-outlined">save</span>
403 {id ? 'Update Product' : 'Create Product'}
404 </>
405 )}
406 </button>
407 <button className="btn btn-secondary" onClick={() => navigate('/products')} disabled={loading}>
408 Cancel
409 </button>
410 </div>
411 </div>
412 )}
413 </div>
414 </MainLayout>
415 );
416}