EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
PurchaseOrderForm.jsx
Go to the documentation of this file.
1import { useEffect, useState } from 'react';
2import { useLocation, useNavigate, useParams } from 'react-router-dom';
3import MainLayout from '../components/Layout/MainLayout';
4// import removed: TenantSelector
5import './PurchaseOrderForm.css';
6import { apiFetch } from '../lib/api';
7
8export default function PurchaseOrderForm() {
9 const navigate = useNavigate();
10 const { id } = useParams();
11 const location = useLocation();
12 const prefillLines = location.state?.lines || [];
13 const [selectedTenantId, setSelectedTenantId] = useState('');
14
15 const [header, setHeader] = useState({
16 po_number: '',
17 supplier: '',
18 bill_to: '',
19 ship_to: '',
20 internal_notes: '',
21 status: 'draft',
22 related_ticket_id: '',
23 related_customer_id: ''
24 });
25 const [lines, setLines] = useState(prefillLines.length ? prefillLines.map(l => ({ sku: l.sku || '', description: l.description || l.name || '', qty_ordered: l.needed_qty || 1, unit_price: l.price_retail || 0, product_id: l.product_id })) : [{ sku: '', description: '', qty_ordered: 1, unit_price: 0 }]);
26 const [loading, setLoading] = useState(false);
27 const [loadingData, setLoadingData] = useState(false);
28 const [error, setError] = useState(null);
29
30 useEffect(() => {
31 if (id) fetchPO();
32 }, [id, selectedTenantId]);
33
34 const getTenantHeaders = async (baseHeaders = {}) => {
35 const headers = { ...baseHeaders };
36 if (selectedTenantId) {
37 const token = localStorage.getItem('token');
38 const tenantRes = await apiFetch('/tenants', {
39 headers: {
40 'Authorization': `Bearer ${token}`,
41 'X-Tenant-Subdomain': 'admin'
42 }
43 });
44 if (tenantRes.ok) {
45 const data = await tenantRes.json();
46 const tenants = Array.isArray(data) ? data : (data.tenants || []);
47 const tenant = tenants.find(t => t.tenant_id === parseInt(selectedTenantId));
48 if (tenant) {
49 headers['X-Tenant-Subdomain'] = tenant.subdomain;
50 }
51 }
52 }
53 return headers;
54 };
55
56 async function fetchPO() {
57 setLoadingData(true);
58 try {
59 const token = localStorage.getItem('token');
60 const url = `/purchase-orders/${id}`;
61 const res = await apiFetch(url, { headers: { Authorization: `Bearer ${token}` } });
62 if (!res.ok) throw new Error('Failed to load');
63 const data = await res.json();
64 // Auto-select the tenant if the PO belongs to a different tenant
65 if (data.purchase_order.tenant_id && !selectedTenantId) {
66 setSelectedTenantId(String(data.purchase_order.tenant_id));
67 }
68 setHeader({
69 po_number: data.purchase_order.po_number || '',
70 supplier: data.purchase_order.supplier || '',
71 bill_to: data.purchase_order.bill_to || '',
72 ship_to: data.purchase_order.ship_to || '',
73 internal_notes: data.purchase_order.internal_notes || '',
74 status: data.purchase_order.status || 'draft',
75 related_ticket_id: data.purchase_order.related_ticket_id || '',
76 related_customer_id: data.purchase_order.related_customer_id || ''
77 });
78 setLines((data.lines || []).map(l => ({
79 po_line_id: l.po_line_id,
80 product_id: l.product_id,
81 sku: l.sku || '',
82 description: l.description || '',
83 qty_ordered: l.qty_ordered || 1,
84 unit_price: l.unit_price || 0
85 })));
86 setError(null);
87 } catch (err) {
88 setError(err.message || 'Failed to load');
89 } finally {
90 setLoadingData(false);
91 }
92 }
93
94 function updateLine(idx, patch) {
95 setLines(prev => prev.map((l, i) => i === idx ? { ...l, ...patch } : l));
96 }
97
98 function addLine() {
99 setLines(prev => [...prev, { sku: '', description: '', qty_ordered: 1, unit_price: 0 }]);
100 }
101
102 function removeLine(idx) {
103 setLines(prev => prev.filter((_, i) => i !== idx));
104 }
105
106 async function save() {
107 setLoading(true);
108 try {
109 const token = localStorage.getItem('token');
110 const headers = await getTenantHeaders({
111 'Content-Type': 'application/json',
112 Authorization: `Bearer ${token}`
113 });
114 const method = id ? 'PUT' : 'POST';
115 const url = id ? `/purchase-orders/${id}` : '/purchase-orders';
116
117 if (!id) {
118 // create header + lines
119 const res = await apiFetch(url, {
120 method,
121 headers,
122 body: JSON.stringify({ ...header, lines })
123 });
124 if (!res.ok) throw new Error('Save failed');
125 } else {
126 // update header
127 const res = await apiFetch(url, {
128 method,
129 headers,
130 body: JSON.stringify(header)
131 });
132 if (!res.ok) throw new Error('Save failed');
133 }
134
135 navigate('/purchase-orders');
136 } catch (err) {
137 setError(err.message || 'Save error');
138 } finally {
139 setLoading(false);
140 }
141 }
142
143 const calculateTotal = () => {
144 return lines.reduce((sum, line) => sum + ((line.qty_ordered || 0) * (line.unit_price || 0)), 0);
145 };
146
147 return (
148 <MainLayout>
149 <div className="page-content">
150 <div className="page-header">
151 <h2>{id ? 'Edit Purchase Order' : 'New Purchase Order'}</h2>
152 <div className="header-actions">
153 <button className="btn btn-secondary" onClick={() => navigate('/purchase-orders')}>
154 <span className="material-symbols-outlined">arrow_back</span>
155 Back
156 </button>
157 </div>
158 </div>
159
160 {error && <div className="alert alert-error">{error}</div>}
161
162 {loadingData ? (
163 <div className="loading-state">
164 <span className="material-symbols-outlined spinning">progress_activity</span>
165 <p>Loading purchase order...</p>
166 </div>
167 ) : (
168 <div className="po-form-container">
169 <div className="form-card">
170 <div className="card-header">
171 <h3>
172 <span className="material-symbols-outlined">receipt_long</span>
173 Purchase Order Information
174 </h3>
175 </div>
176 <div className="card-body">
177 <div className="form-row">
178 <div className="form-group">
179 <label htmlFor="po_number">PO Number</label>
180 <input
181 id="po_number"
182 type="text"
183 value={header.po_number}
184 onChange={e => setHeader(h => ({ ...h, po_number: e.target.value }))}
185 placeholder="Auto-generated or manual"
186 />
187 </div>
188 <div className="form-group">
189 <label htmlFor="supplier">Supplier *</label>
190 <input
191 id="supplier"
192 type="text"
193 value={header.supplier}
194 onChange={e => setHeader(h => ({ ...h, supplier: e.target.value }))}
195 placeholder="Supplier name"
196 required
197 />
198 </div>
199 </div>
200
201 <div className="form-row">
202 <div className="form-group">
203 <label htmlFor="status">Status</label>
204 <select
205 id="status"
206 value={header.status}
207 onChange={e => setHeader(h => ({ ...h, status: e.target.value }))}
208 >
209 <option value="draft">Draft</option>
210 <option value="submitted">Submitted</option>
211 <option value="received">Received</option>
212 <option value="cancelled">Cancelled</option>
213 </select>
214 </div>
215 <div className="form-group">
216 <label htmlFor="related_ticket_id">Related Ticket</label>
217 <input
218 id="related_ticket_id"
219 type="number"
220 value={header.related_ticket_id}
221 onChange={e => setHeader(h => ({ ...h, related_ticket_id: e.target.value }))}
222 placeholder="Ticket ID (optional)"
223 />
224 </div>
225 </div>
226 </div>
227 </div>
228
229 <div className="form-card">
230 <div className="card-header">
231 <h3>
232 <span className="material-symbols-outlined">location_on</span>
233 Addresses
234 </h3>
235 </div>
236 <div className="card-body">
237 <div className="form-row">
238 <div className="form-group">
239 <label htmlFor="bill_to">Bill To Address</label>
240 <textarea
241 id="bill_to"
242 value={header.bill_to}
243 onChange={e => setHeader(h => ({ ...h, bill_to: e.target.value }))}
244 placeholder="Company Name&#10;Street Address&#10;City, State ZIP"
245 rows="4"
246 />
247 </div>
248 <div className="form-group">
249 <label htmlFor="ship_to">Ship To Address</label>
250 <textarea
251 id="ship_to"
252 value={header.ship_to}
253 onChange={e => setHeader(h => ({ ...h, ship_to: e.target.value }))}
254 placeholder="Warehouse/Location&#10;Street Address&#10;City, State ZIP"
255 rows="4"
256 />
257 </div>
258 </div>
259
260 <div className="form-group">
261 <label htmlFor="related_customer_id">Related Customer (for dropship)</label>
262 <input
263 id="related_customer_id"
264 type="number"
265 value={header.related_customer_id}
266 onChange={e => setHeader(h => ({ ...h, related_customer_id: e.target.value }))}
267 placeholder="Customer ID (optional)"
268 />
269 </div>
270
271 <div className="form-group">
272 <label htmlFor="internal_notes">Internal Notes</label>
273 <textarea
274 id="internal_notes"
275 value={header.internal_notes}
276 onChange={e => setHeader(h => ({ ...h, internal_notes: e.target.value }))}
277 placeholder="Notes for internal staff only"
278 rows="3"
279 />
280 </div>
281 </div>
282 </div>
283
284 <div className="form-card">
285 <div className="card-header">
286 <h3>
287 <span className="material-symbols-outlined">shopping_cart</span>
288 Line Items
289 </h3>
290 <button className="btn btn-sm" onClick={addLine} type="button">
291 <span className="material-symbols-outlined">add</span>
292 Add Line
293 </button>
294 </div>
295 <div className="card-body">
296 <div className="line-items-table">
297 <table className="data-table">
298 <thead>
299 <tr>
300 <th>SKU</th>
301 <th>Description</th>
302 <th>Qty</th>
303 <th>Unit Price</th>
304 <th>Total</th>
305 <th></th>
306 </tr>
307 </thead>
308 <tbody>
309 {lines.map((l, idx) => (
310 <tr key={idx}>
311 <td>
312 <input
313 type="text"
314 value={l.sku}
315 onChange={e => updateLine(idx, { sku: e.target.value })}
316 placeholder="SKU"
317 />
318 </td>
319 <td>
320 <input
321 type="text"
322 value={l.description}
323 onChange={e => updateLine(idx, { description: e.target.value })}
324 placeholder="Item description"
325 />
326 </td>
327 <td>
328 <input
329 type="number"
330 min={1}
331 value={l.qty_ordered}
332 onChange={e => updateLine(idx, { qty_ordered: parseInt(e.target.value || 0, 10) })}
333 />
334 </td>
335 <td>
336 <input
337 type="number"
338 step="0.01"
339 value={l.unit_price}
340 onChange={e => updateLine(idx, { unit_price: parseFloat(e.target.value || 0) })}
341 placeholder="0.00"
342 />
343 </td>
344 <td className="line-total">
345 ${Number((l.qty_ordered || 0) * (l.unit_price || 0)).toFixed(2)}
346 </td>
347 <td>
348 <button
349 className="btn btn-icon btn-danger"
350 onClick={() => removeLine(idx)}
351 type="button"
352 title="Remove line"
353 >
354 <span className="material-symbols-outlined">delete</span>
355 </button>
356 </td>
357 </tr>
358 ))}
359 </tbody>
360 <tfoot>
361 <tr className="total-row">
362 <td colSpan="4" className="text-right"><strong>Total:</strong></td>
363 <td className="total-amount">${calculateTotal().toFixed(2)}</td>
364 <td></td>
365 </tr>
366 </tfoot>
367 </table>
368 </div>
369 </div>
370 </div>
371
372 <div className="form-actions">
373 <button className="btn btn-primary" onClick={save} disabled={loading || !header.supplier}>
374 {loading ? (
375 <>
376 <span className="material-symbols-outlined spinning">progress_activity</span>
377 Saving...
378 </>
379 ) : (
380 <>
381 <span className="material-symbols-outlined">save</span>
382 {id ? 'Update Purchase Order' : 'Create Purchase Order'}
383 </>
384 )}
385 </button>
386 <button className="btn btn-secondary" onClick={() => navigate('/purchase-orders')} disabled={loading}>
387 Cancel
388 </button>
389 </div>
390 </div>
391 )}
392 </div>
393 </MainLayout>
394 );
395}