PluginProbe
WPBot – AI ChatBot for Live Support, Lead Generation, WordPress Automation, AI Services / 8.8.1
WPBot – AI ChatBot for Live Support, Lead Generation, WordPress Automation, AI Services v8.8.1
8.8.1 8.8.0 8.7.9 8.7.8 8.7.7 8.7.6 8.7.5 8.7.4 8.7.3 8.7.2 8.7.1 8.7.0 8.6.9 8.6.8 8.6.7 8.6.6 8.6.5 8.6.4 8.6.2 8.6.1 8.6.0 8.5.9 8.5.8 8.5.7 8.5.6 All 537 releases
chatbot / addons / automator / src / TablesApp.jsx

TablesApp.jsx in WPBot – AI ChatBot for Live Support, Lead Generation, WordPress Automation, AI Services 8.8.1, at addons/automator/src/TablesApp.jsx

756 lines 36.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useState, useEffect, useRef } from 'react';
2
3 // ── Constants ──────────────────────────────────────────────────────────────
4
5 const COL_TYPES = [
6 { id: 'text', label: 'Text', badge: 'T' },
7 { id: 'textarea', label: 'Long Text', badge: '' },
8 { id: 'number', label: 'Number', badge: '#' },
9 { id: 'email', label: 'Email', badge: '@' },
10 { id: 'url', label: 'URL', badge: '🔗' },
11 { id: 'date', label: 'Date', badge: '�
12 ' },
13 { id: 'datetime', label: 'Date+Time', badge: '⏱' },
14 { id: 'boolean', label: 'Boolean', badge: '✓' },
15 ];
16
17 const api = (path, opts = {}) =>
18 fetch(`${window.wpbotAutomator.apiUrl}${path}`, {
19 headers: {
20 'Content-Type': 'application/json',
21 'X-WP-Nonce': window.wpbotAutomator.nonce,
22 ...( opts.headers || {} ),
23 },
24 ...opts,
25 }).then(r => r.json());
26
27 // ── Root app ────────────────────────────────────────────────────────────────
28
29 const TablesApp = () => {
30 const [view, setView] = useState('list'); // 'list' | 'records'
31 const [tables, setTables] = useState([]);
32 const [loading, setLoading] = useState(true);
33 const [activeTable, setActive] = useState(null);
34 const [showCreate, setShowCreate] = useState(false);
35
36 const loadTables = async () => {
37 setLoading(true);
38 try {
39 const data = await api('/tables');
40 setTables(Array.isArray(data) ? data : []);
41 } finally {
42 setLoading(false);
43 }
44 };
45
46 useEffect(() => { loadTables(); }, []);
47
48 const openRecords = (table) => { setActive(table); setView('records'); };
49 const backToList = () => { setView('list'); setActive(null); loadTables(); };
50
51 const handleDelete = async (table) => {
52 if (!window.confirm(`Delete table "${table.name}" and all its data? This cannot be undone.`)) return;
53 await api(`/tables/${table.id}`, { method: 'DELETE' });
54 loadTables();
55 };
56
57 if (view === 'records' && activeTable) {
58 return <RecordsManager table={activeTable} onBack={backToList} />;
59 }
60
61 return (
62 <div className="p-8 bg-background min-h-screen text-foreground">
63 <div className="max-w-6xl mx-auto">
64
65 {/* Header */}
66 <div className="flex justify-between items-center mb-8">
67 <div>
68 <h1 className="text-2xl font-bold">Tables</h1>
69 <p className="text-sm text-muted-foreground mt-1">
70 Create and manage database tables. Use <code className="bg-muted px-1 rounded text-xs">[wpbot_table id="X"]</code> to display on any page.
71 </p>
72 </div>
73 <button
74 onClick={() => setShowCreate(true)}
75 className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
76 >
77 + Create New Table
78 </button>
79 </div>
80
81 {/* Tables grid */}
82 {loading ? (
83 <div className="text-center py-12 text-muted-foreground">Loading tables...</div>
84 ) : tables.length === 0 ? (
85 <div className="text-center py-16 border-2 border-dashed border-border rounded-xl text-muted-foreground">
86 <p className="text-lg font-medium mb-2">No tables yet</p>
87 <p className="text-sm">Create your first table to start managing data.</p>
88 </div>
89 ) : (
90 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
91 {tables.map(t => (
92 <TableCard
93 key={t.id}
94 table={t}
95 onOpen={() => openRecords(t)}
96 onDelete={() => handleDelete(t)}
97 />
98 ))}
99 </div>
100 )}
101 </div>
102
103 {showCreate && (
104 <CreateTableModal
105 onClose={() => setShowCreate(false)}
106 onCreated={() => { setShowCreate(false); loadTables(); }}
107 />
108 )}
109 </div>
110 );
111 };
112
113 // ── Table card ──────────────────────────────────────────────────────────────
114
115 const TableCard = ({ table, onOpen, onDelete }) => (
116 <div className="group p-6 bg-card border border-border rounded-xl hover:shadow-md transition-shadow">
117 <div className="flex justify-between items-start mb-3">
118 <div>
119 <h3 className="font-semibold text-lg leading-tight">{table.name}</h3>
120 <p className="text-xs text-muted-foreground mt-0.5">
121 {table.columns?.length || 0} columns · {table.record_count ?? 0} records
122 </p>
123 </div>
124 <button
125 onClick={onDelete}
126 className="opacity-0 group-hover:opacity-100 p-1.5 text-rose-500 hover:bg-rose-50 rounded transition-all"
127 title="Delete table"
128 >
129 <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
130 </button>
131 </div>
132
133 {/* Column pills */}
134 <div className="flex flex-wrap gap-1 mb-4 min-h-[24px]">
135 {(table.columns || []).slice(0, 5).map(col => (
136 <span key={col.slug} className="text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded font-medium">
137 {col.name}
138 </span>
139 ))}
140 {(table.columns?.length || 0) > 5 && (
141 <span className="text-[10px] text-muted-foreground">+{table.columns.length - 5} more</span>
142 )}
143 </div>
144
145 {/* Shortcode */}
146 <div className="text-[11px] font-mono bg-muted/60 rounded px-2 py-1 text-muted-foreground mb-4 truncate">
147 {`[wpbot_table id="${table.id}"]`}
148 </div>
149
150 <button
151 onClick={onOpen}
152 className="w-full py-2 border border-primary/30 text-primary text-sm font-medium rounded-lg hover:bg-primary hover:text-primary-foreground transition-colors"
153 >
154 Manage Records →
155 </button>
156 </div>
157 );
158
159 // ── Create table modal ───────────────────────────────────────────────────────
160
161 const CreateTableModal = ({ onClose, onCreated }) => {
162 const [name, setName] = useState('');
163 const [columns, setCols] = useState([{ id: 1, name: '', type: 'text', required: false }]);
164 const [saving, setSaving] = useState(false);
165 const [error, setError] = useState('');
166 const nextId = useRef(2);
167
168 const addCol = () => {
169 setCols(c => [...c, { id: nextId.current++, name: '', type: 'text', required: false }]);
170 };
171
172 const removeCol = (id) => setCols(c => c.filter(col => col.id !== id));
173
174 const updateCol = (id, patch) =>
175 setCols(c => c.map(col => col.id === id ? { ...col, ...patch } : col));
176
177 const handleSubmit = async () => {
178 if (!name.trim()) { setError('Table name is required.'); return; }
179 const validCols = columns.filter(c => c.name.trim());
180 if (!validCols.length) { setError('At least one column with a name is required.'); return; }
181
182 setError('');
183 setSaving(true);
184 try {
185 const res = await api('/tables', {
186 method: 'POST',
187 body: JSON.stringify({ name: name.trim(), columns: validCols }),
188 });
189 if (res.success) { onCreated(); }
190 else { setError(res.message || 'Failed to create table.'); }
191 } finally {
192 setSaving(false);
193 }
194 };
195
196 return (
197 <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
198 <div className="bg-background rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col">
199
200 {/* Header */}
201 <div className="flex justify-between items-center p-6 border-b border-border">
202 <h2 className="text-lg font-semibold">Create New Table</h2>
203 <button onClick={onClose} className="p-1.5 hover:bg-muted rounded-lg transition-colors">
204 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
205 </button>
206 </div>
207
208 {/* Body */}
209 <div className="p-6 overflow-y-auto flex-1 space-y-5">
210 {/* Table name */}
211 <div>
212 <label className="block text-sm font-medium mb-1.5">Table Name</label>
213 <input
214 type="text"
215 value={name}
216 onChange={e => setName(e.target.value)}
217 placeholder="e.g. Customers, Products, Contacts"
218 className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/30"
219 />
220 </div>
221
222 {/* Columns */}
223 <div>
224 <label className="block text-sm font-medium mb-2">Columns</label>
225 <div className="space-y-2">
226 {columns.map(col => (
227 <div key={col.id} className="flex items-center gap-2">
228 <input
229 type="text"
230 value={col.name}
231 onChange={e => updateCol(col.id, { name: e.target.value })}
232 placeholder="Column name"
233 className="flex-1 border border-border rounded-lg px-3 py-2 text-sm bg-background focus:outline-none"
234 />
235 <select
236 value={col.type}
237 onChange={e => updateCol(col.id, { type: e.target.value })}
238 className="border border-border rounded-lg px-2 py-2 text-sm bg-background focus:outline-none"
239 >
240 {COL_TYPES.map(t => (
241 <option key={t.id} value={t.id}>{t.label}</option>
242 ))}
243 </select>
244 <label className="flex items-center gap-1.5 text-xs text-muted-foreground whitespace-nowrap cursor-pointer">
245 <input
246 type="checkbox"
247 checked={col.required}
248 onChange={e => updateCol(col.id, { required: e.target.checked })}
249 className="rounded"
250 />
251 Required
252 </label>
253 <button
254 onClick={() => removeCol(col.id)}
255 disabled={columns.length === 1}
256 className="p-1.5 text-muted-foreground hover:text-rose-500 hover:bg-rose-50 rounded transition-colors disabled:opacity-30"
257 >
258 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
259 </button>
260 </div>
261 ))}
262 </div>
263 <button
264 onClick={addCol}
265 className="mt-3 text-sm text-primary hover:text-primary/80 font-medium flex items-center gap-1"
266 >
267 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
268 Add Column
269 </button>
270 </div>
271
272 {error && <p className="text-sm text-rose-600 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2">{error}</p>}
273 </div>
274
275 {/* Footer */}
276 <div className="flex justify-end gap-3 p-6 border-t border-border">
277 <button onClick={onClose} className="px-4 py-2 text-sm border border-border rounded-lg hover:bg-muted transition-colors">
278 Cancel
279 </button>
280 <button
281 onClick={handleSubmit}
282 disabled={saving}
283 className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors disabled:opacity-60"
284 >
285 {saving ? 'Creating…' : 'Create Table'}
286 </button>
287 </div>
288 </div>
289 </div>
290 );
291 };
292
293 // ── Records manager ──────────────────────────────────────────────────────────
294
295 const RecordsManager = ({ table, onBack }) => {
296 const [records, setRecords] = useState([]);
297 const [total, setTotal] = useState(0);
298 const [page, setPage] = useState(1);
299 const [totalPages, setTotalPages] = useState(1);
300 const [loading, setLoading] = useState(true);
301 const [filterCol, setFilterCol] = useState('');
302 const [filterVal, setFilterVal] = useState('');
303 const [appliedFilter, setApplied] = useState({ col: '', val: '' });
304 const [showAddRow, setShowAdd] = useState(false);
305 const [editRow, setEditRow] = useState(null);
306 const [showImport, setShowImport] = useState(false);
307 const perPage = 50;
308
309 const loadRecords = async (pg = 1, fCol = appliedFilter.col, fVal = appliedFilter.val) => {
310 setLoading(true);
311 try {
312 const params = new URLSearchParams({ page: pg, per_page: perPage });
313 if (fCol && fVal) { params.set('filter_col', fCol); params.set('filter_val', fVal); }
314 const data = await api(`/tables/${table.id}/records?${params}`);
315 setRecords(data.records || []);
316 setTotal(data.total || 0);
317 setTotalPages(data.total_pages || 1);
318 setPage(pg);
319 } finally {
320 setLoading(false);
321 }
322 };
323
324 useEffect(() => { loadRecords(1); }, []);
325
326 const applyFilter = () => {
327 setApplied({ col: filterCol, val: filterVal });
328 loadRecords(1, filterCol, filterVal);
329 };
330
331 const clearFilter = () => {
332 setFilterCol(''); setFilterVal('');
333 setApplied({ col: '', val: '' });
334 loadRecords(1, '', '');
335 };
336
337 const handleDelete = async (rowId) => {
338 if (!window.confirm('Delete this record?')) return;
339 await api(`/tables/${table.id}/records/${rowId}`, { method: 'DELETE' });
340 loadRecords(page);
341 };
342
343 const handleExport = async () => {
344 const data = await api(`/tables/${table.id}/export`);
345 if (!data.csv) return;
346 const blob = new Blob([data.csv], { type: 'text/csv' });
347 const url = URL.createObjectURL(blob);
348 const a = document.createElement('a');
349 a.href = url; a.download = data.filename || 'export.csv'; a.click();
350 URL.revokeObjectURL(url);
351 };
352
353 return (
354 <div className="p-8 bg-background min-h-screen text-foreground">
355 <div className="max-w-7xl mx-auto">
356
357 {/* Header */}
358 <div className="flex items-center justify-between mb-6">
359 <div className="flex items-center gap-3">
360 <button onClick={onBack} className="p-2 hover:bg-muted rounded-lg transition-colors text-muted-foreground">
361 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
362 </button>
363 <div>
364 <h1 className="text-xl font-bold">{table.name}</h1>
365 <p className="text-xs text-muted-foreground">
366 {total} records · <code className="bg-muted px-1 rounded">{`[wpbot_table id="${table.id}"]`}</code>
367 </p>
368 </div>
369 </div>
370 <div className="flex items-center gap-2">
371 <button onClick={() => setShowImport(true)} className="flex items-center gap-1.5 px-3 py-2 border border-border text-sm rounded-lg hover:bg-muted transition-colors">
372 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
373 Import CSV
374 </button>
375 <button onClick={handleExport} className="flex items-center gap-1.5 px-3 py-2 border border-border text-sm rounded-lg hover:bg-muted transition-colors">
376 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
377 Export CSV
378 </button>
379 <button onClick={() => setShowAdd(true)} className="px-3 py-2 bg-primary text-primary-foreground text-sm rounded-lg hover:bg-primary/90 transition-colors">
380 + Add Record
381 </button>
382 </div>
383 </div>
384
385 {/* Filters */}
386 <div className="flex items-center gap-3 mb-5 p-4 bg-muted/30 border border-border rounded-xl">
387 <span className="text-sm font-medium text-muted-foreground shrink-0">Filter by:</span>
388 <select
389 value={filterCol}
390 onChange={e => setFilterCol(e.target.value)}
391 className="border border-border rounded-lg px-2.5 py-1.5 text-sm bg-background focus:outline-none"
392 >
393 <option value="">— Column —</option>
394 {table.columns?.map(col => (
395 <option key={col.slug} value={col.slug}>{col.name}</option>
396 ))}
397 </select>
398 <input
399 type="text"
400 value={filterVal}
401 onChange={e => setFilterVal(e.target.value)}
402 onKeyDown={e => e.key === 'Enter' && applyFilter()}
403 placeholder="Filter value…"
404 className="border border-border rounded-lg px-3 py-1.5 text-sm bg-background focus:outline-none w-48"
405 />
406 <button onClick={applyFilter} className="px-3 py-1.5 bg-primary text-primary-foreground text-sm rounded-lg hover:bg-primary/90 transition-colors">
407 Apply
408 </button>
409 {(appliedFilter.col || appliedFilter.val) && (
410 <button onClick={clearFilter} className="px-3 py-1.5 border border-border text-sm rounded-lg hover:bg-muted transition-colors">
411 Clear
412 </button>
413 )}
414 <span className="ml-auto text-xs text-muted-foreground">{total} records</span>
415 </div>
416
417 {/* Table */}
418 <div className="border border-border rounded-xl overflow-hidden">
419 <div className="overflow-x-auto">
420 <table className="w-full text-sm">
421 <thead>
422 <tr className="bg-muted/50 border-b border-border">
423 <th className="text-left px-4 py-3 font-medium text-muted-foreground w-12">#</th>
424 {table.columns?.map(col => (
425 <th key={col.slug} className="text-left px-4 py-3 font-medium text-muted-foreground whitespace-nowrap">
426 {col.name}
427 <span className="ml-1 text-[10px] text-muted-foreground/60 font-normal">{col.type}</span>
428 </th>
429 ))}
430 <th className="text-left px-4 py-3 font-medium text-muted-foreground w-24">Actions</th>
431 </tr>
432 </thead>
433 <tbody>
434 {loading ? (
435 <tr><td colSpan={( table.columns?.length || 0) + 2} className="text-center py-10 text-muted-foreground">Loading…</td></tr>
436 ) : records.length === 0 ? (
437 <tr><td colSpan={(table.columns?.length || 0) + 2} className="text-center py-10 text-muted-foreground">No records found.</td></tr>
438 ) : records.map(rec => (
439 <tr key={rec.id} className="border-b border-border/50 hover:bg-muted/20 transition-colors">
440 <td className="px-4 py-3 text-muted-foreground text-xs">{rec.id}</td>
441 {table.columns?.map(col => (
442 <td key={col.slug} className="px-4 py-3 max-w-[240px]">
443 <span className="truncate block" title={rec[col.slug] ?? ''}>
444 {col.type === 'boolean'
445 ? (rec[col.slug] == 1 ? '✓' : '✗')
446 : rec[col.slug] ?? ''}
447 </span>
448 </td>
449 ))}
450 <td className="px-4 py-3">
451 <div className="flex items-center gap-1">
452 <button
453 onClick={() => setEditRow(rec)}
454 className="p-1.5 text-blue-500 hover:bg-blue-50 rounded transition-colors"
455 title="Edit"
456 >
457 <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
458 </button>
459 <button
460 onClick={() => handleDelete(rec.id)}
461 className="p-1.5 text-rose-500 hover:bg-rose-50 rounded transition-colors"
462 title="Delete"
463 >
464 <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
465 </button>
466 </div>
467 </td>
468 </tr>
469 ))}
470 </tbody>
471 </table>
472 </div>
473 </div>
474
475 {/* Pagination */}
476 {totalPages > 1 && (
477 <div className="flex justify-center items-center gap-2 mt-5">
478 <button disabled={page <= 1} onClick={() => loadRecords(page - 1)} className="px-3 py-1.5 border border-border rounded-lg text-sm disabled:opacity-40 hover:bg-muted transition-colors">
479 ← Prev
480 </button>
481 <span className="text-sm text-muted-foreground">Page {page} of {totalPages}</span>
482 <button disabled={page >= totalPages} onClick={() => loadRecords(page + 1)} className="px-3 py-1.5 border border-border rounded-lg text-sm disabled:opacity-40 hover:bg-muted transition-colors">
483 Next →
484 </button>
485 </div>
486 )}
487 </div>
488
489 {/* Add / Edit record modal */}
490 {(showAddRow || editRow) && (
491 <RecordModal
492 table={table}
493 record={editRow}
494 onClose={() => { setShowAdd(false); setEditRow(null); }}
495 onSaved={() => { setShowAdd(false); setEditRow(null); loadRecords(page); }}
496 />
497 )}
498
499 {/* CSV import modal */}
500 {showImport && (
501 <CsvImportModal
502 table={table}
503 onClose={() => setShowImport(false)}
504 onImported={() => { setShowImport(false); loadRecords(1); }}
505 />
506 )}
507 </div>
508 );
509 };
510
511 // ── Record add / edit modal ──────────────────────────────────────────────────
512
513 const RecordModal = ({ table, record, onClose, onSaved }) => {
514 const isEdit = !!record;
515 const initial = {};
516 table.columns?.forEach(col => {
517 initial[col.slug] = isEdit ? (record[col.slug] ?? '') : (col.type === 'boolean' ? false : '');
518 });
519
520 const [form, setForm] = useState(initial);
521 const [saving, setSaving] = useState(false);
522 const [error, setError] = useState('');
523
524 const set = (slug, val) => setForm(f => ({ ...f, [slug]: val }));
525
526 const handleSave = async () => {
527 setSaving(true); setError('');
528 try {
529 const url = isEdit ? `/tables/${table.id}/records/${record.id}` : `/tables/${table.id}/records`;
530 const method = isEdit ? 'PUT' : 'POST';
531 const res = await api(url, { method, body: JSON.stringify({ data: form }) });
532 if (res.success) { onSaved(); }
533 else { setError(res.message || 'Failed to save record.'); }
534 } finally {
535 setSaving(false);
536 }
537 };
538
539 return (
540 <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
541 <div className="bg-background rounded-2xl shadow-2xl w-full max-w-lg max-h-[85vh] flex flex-col">
542 <div className="flex justify-between items-center p-6 border-b border-border">
543 <h2 className="text-base font-semibold">{isEdit ? 'Edit Record' : 'Add Record'}</h2>
544 <button onClick={onClose} className="p-1.5 hover:bg-muted rounded-lg"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
545 </div>
546 <div className="p-6 overflow-y-auto flex-1 space-y-4">
547 {table.columns?.map(col => (
548 <div key={col.slug}>
549 <label className="block text-sm font-medium mb-1">
550 {col.name}
551 {col.required && <span className="text-rose-500 ml-0.5">*</span>}
552 <span className="ml-1.5 text-[10px] text-muted-foreground font-normal">{col.type}</span>
553 </label>
554 {col.type === 'boolean' ? (
555 <label className="flex items-center gap-2 cursor-pointer">
556 <input
557 type="checkbox"
558 checked={!!form[col.slug]}
559 onChange={e => set(col.slug, e.target.checked)}
560 className="w-4 h-4 rounded"
561 />
562 <span className="text-sm">Yes</span>
563 </label>
564 ) : col.type === 'textarea' ? (
565 <textarea
566 value={form[col.slug]}
567 onChange={e => set(col.slug, e.target.value)}
568 rows={3}
569 className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background focus:outline-none resize-none"
570 />
571 ) : (
572 <input
573 type={col.type === 'number' ? 'number' : col.type === 'date' ? 'date' : col.type === 'datetime' ? 'datetime-local' : col.type === 'email' ? 'email' : col.type === 'url' ? 'url' : 'text'}
574 value={form[col.slug]}
575 onChange={e => set(col.slug, e.target.value)}
576 className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background focus:outline-none"
577 />
578 )}
579 </div>
580 ))}
581 {error && <p className="text-sm text-rose-600 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2">{error}</p>}
582 </div>
583 <div className="flex justify-end gap-3 p-6 border-t border-border">
584 <button onClick={onClose} className="px-4 py-2 text-sm border border-border rounded-lg hover:bg-muted transition-colors">Cancel</button>
585 <button onClick={handleSave} disabled={saving} className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-60">
586 {saving ? 'Saving…' : isEdit ? 'Save Changes' : 'Add Record'}
587 </button>
588 </div>
589 </div>
590 </div>
591 );
592 };
593
594 // ── CSV import modal ─────────────────────────────────────────────────────────
595
596 const CsvImportModal = ({ table, onClose, onImported }) => {
597 const [step, setStep] = useState('upload'); // 'upload' | 'preview'
598 const [csvHeaders, setCsvHeaders] = useState([]);
599 const [csvRows, setCsvRows] = useState([]);
600 const [mapping, setMapping] = useState({});
601 const [importing, setImporting] = useState(false);
602 const [result, setResult] = useState(null);
603 const [error, setError] = useState('');
604 const fileRef = useRef(null);
605
606 const parseCsv = (text) => {
607 const lines = text.trim().split('\n');
608 if (!lines.length) return;
609 const parse = (line) => {
610 const result = []; let cur = ''; let inQ = false;
611 for (const ch of line) {
612 if (ch === '"') { inQ = !inQ; }
613 else if (ch === ',' && !inQ) { result.push(cur); cur = ''; }
614 else { cur += ch; }
615 }
616 result.push(cur);
617 return result.map(v => v.trim().replace(/^"|"$/g, ''));
618 };
619 const headers = parse(lines[0]);
620 const rows = lines.slice(1).map(parse).filter(r => r.some(v => v));
621 setCsvHeaders(headers);
622 setCsvRows(rows);
623
624 // Auto-map headers to column slugs by name.
625 const colByName = {};
626 table.columns?.forEach(col => {
627 colByName[col.name.toLowerCase()] = col.slug;
628 colByName[col.slug.toLowerCase()] = col.slug;
629 });
630 const autoMap = {};
631 headers.forEach((h, i) => {
632 const match = colByName[h.toLowerCase()];
633 if (match) autoMap[i] = match;
634 });
635 setMapping(autoMap);
636 setStep('preview');
637 };
638
639 const handleFile = (e) => {
640 const file = e.target.files?.[0];
641 if (!file) return;
642 const reader = new FileReader();
643 reader.onload = (ev) => parseCsv(ev.target.result);
644 reader.readAsText(file);
645 e.target.value = '';
646 };
647
648 const handleImport = async () => {
649 setImporting(true); setError('');
650 try {
651 const headers = csvHeaders.map((h, i) => mapping[i] || null);
652 const filteredRows = csvRows.map(row => row.map((v, i) => mapping[i] !== undefined ? v : null));
653 const res = await api(`/tables/${table.id}/import`, {
654 method: 'POST',
655 body: JSON.stringify({ headers: csvHeaders, rows: csvRows }),
656 });
657 if (res.success) { setResult(res.imported); }
658 else { setError(res.message || 'Import failed.'); }
659 } finally {
660 setImporting(false);
661 }
662 };
663
664 return (
665 <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
666 <div className="bg-background rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col">
667 <div className="flex justify-between items-center p-6 border-b border-border">
668 <h2 className="text-base font-semibold">Import CSV</h2>
669 <button onClick={onClose} className="p-1.5 hover:bg-muted rounded-lg"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
670 </div>
671
672 <div className="p-6 overflow-y-auto flex-1">
673 {result !== null ? (
674 <div className="text-center py-8">
675 <div className="w-14 h-14 bg-emerald-100 rounded-full flex items-center justify-center mx-auto mb-4">
676 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#059669" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
677 </div>
678 <p className="text-lg font-semibold">Import Complete</p>
679 <p className="text-muted-foreground mt-1">{result} records imported successfully.</p>
680 <button onClick={onImported} className="mt-5 px-5 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90">Done</button>
681 </div>
682 ) : step === 'upload' ? (
683 <div
684 onClick={() => fileRef.current?.click()}
685 className="border-2 border-dashed border-border rounded-xl p-12 text-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors"
686 >
687 <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="mx-auto mb-3 text-muted-foreground"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
688 <p className="font-medium">Click to select a CSV file</p>
689 <p className="text-sm text-muted-foreground mt-1">The first row must contain column headers.</p>
690 <input ref={fileRef} type="file" accept=".csv,text/csv" style={{ display: 'none' }} onChange={handleFile} />
691 </div>
692 ) : (
693 <div className="space-y-4">
694 <div className="flex items-center justify-between">
695 <p className="text-sm font-medium">{csvRows.length} rows · {csvHeaders.length} columns detected</p>
696 <button onClick={() => setStep('upload')} className="text-xs text-muted-foreground hover:text-foreground">← Change file</button>
697 </div>
698
699 {/* Column mapping */}
700 <div>
701 <p className="text-sm font-medium mb-2">Column Mapping</p>
702 <div className="space-y-2">
703 {csvHeaders.map((h, i) => (
704 <div key={i} className="flex items-center gap-3 text-sm">
705 <span className="w-36 truncate font-mono text-xs bg-muted px-2 py-1 rounded">{h}</span>
706 <span className="text-muted-foreground text-xs">→</span>
707 <select
708 value={mapping[i] || ''}
709 onChange={e => setMapping(m => ({ ...m, [i]: e.target.value || undefined }))}
710 className="flex-1 border border-border rounded-lg px-2 py-1 text-sm bg-background focus:outline-none"
711 >
712 <option value="">— skip column —</option>
713 {table.columns?.map(col => (
714 <option key={col.slug} value={col.slug}>{col.name}</option>
715 ))}
716 </select>
717 </div>
718 ))}
719 </div>
720 </div>
721
722 {/* Preview rows */}
723 <div>
724 <p className="text-sm font-medium mb-2">Preview (first 3 rows)</p>
725 <div className="overflow-x-auto border border-border rounded-lg">
726 <table className="w-full text-xs">
727 <thead><tr className="bg-muted/50">{csvHeaders.map((h, i) => <th key={i} className="text-left px-3 py-2 font-medium">{h}</th>)}</tr></thead>
728 <tbody>{csvRows.slice(0, 3).map((row, ri) => (<tr key={ri} className="border-t border-border">{row.map((v, ci) => <td key={ci} className="px-3 py-2 max-w-[120px] truncate">{v}</td>)}</tr>))}</tbody>
729 </table>
730 </div>
731 </div>
732
733 {error && <p className="text-sm text-rose-600 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2">{error}</p>}
734 </div>
735 )}
736 </div>
737
738 {step === 'preview' && result === null && (
739 <div className="flex justify-end gap-3 p-6 border-t border-border">
740 <button onClick={onClose} className="px-4 py-2 text-sm border border-border rounded-lg hover:bg-muted transition-colors">Cancel</button>
741 <button
742 onClick={handleImport}
743 disabled={importing || Object.keys(mapping).length === 0}
744 className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-60"
745 >
746 {importing ? 'Importing…' : `Import ${csvRows.length} Rows`}
747 </button>
748 </div>
749 )}
750 </div>
751 </div>
752 );
753 };
754
755 export default TablesApp;
756