251 lines
9.5 KiB
JavaScript
251 lines
9.5 KiB
JavaScript
import { useState, useRef } from 'react'
|
||
import { importContacts } from '../api/api'
|
||
import './ImportContacts.css'
|
||
|
||
/**
|
||
* ImportContacts
|
||
*
|
||
* Opens a modal that lets the user upload a CSV or JSON file of contacts and
|
||
* import them into the current event's guest list.
|
||
*
|
||
* Props:
|
||
* eventId – UUID of the current event
|
||
* onImportComplete – callback called when a real (non-dry-run) import succeeds
|
||
*/
|
||
function ImportContacts({ eventId, onImportComplete }) {
|
||
const [open, setOpen] = useState(false)
|
||
const [file, setFile] = useState(null)
|
||
const [isDryRun, setIsDryRun] = useState(false)
|
||
const [dragging, setDragging] = useState(false)
|
||
const [loading, setLoading] = useState(false)
|
||
const [result, setResult] = useState(null) // ImportContactsResponse
|
||
const [error, setError] = useState('')
|
||
const fileInputRef = useRef()
|
||
|
||
// ── helpers ────────────────────────────────────────────────────────────────
|
||
|
||
const reset = () => {
|
||
setFile(null)
|
||
setResult(null)
|
||
setError('')
|
||
setLoading(false)
|
||
setIsDryRun(false)
|
||
}
|
||
|
||
const handleClose = () => {
|
||
setOpen(false)
|
||
reset()
|
||
}
|
||
|
||
const handleFileChange = (e) => {
|
||
const f = e.target.files?.[0]
|
||
if (f) { setFile(f); setResult(null); setError('') }
|
||
}
|
||
|
||
const handleDrop = (e) => {
|
||
e.preventDefault()
|
||
setDragging(false)
|
||
const f = e.dataTransfer.files?.[0]
|
||
if (f) { setFile(f); setResult(null); setError('') }
|
||
}
|
||
|
||
// ── submit ─────────────────────────────────────────────────────────────────
|
||
|
||
const handleUpload = async () => {
|
||
if (!file) { setError('אנא בחר קובץ לפני ההעלאה.'); return }
|
||
setLoading(true)
|
||
setError('')
|
||
setResult(null)
|
||
|
||
try {
|
||
const res = await importContacts(eventId, file, isDryRun)
|
||
setResult(res)
|
||
if (!isDryRun && (res.created > 0 || res.updated > 0) && onImportComplete) {
|
||
onImportComplete()
|
||
}
|
||
} catch (err) {
|
||
const msg = err?.response?.data?.detail || err.message || 'שגיאה בעת ייבוא הקובץ.'
|
||
setError(typeof msg === 'string' ? msg : JSON.stringify(msg))
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
// ── action label helpers ───────────────────────────────────────────────────
|
||
|
||
const actionLabel = {
|
||
created: { text: 'נוצר', cls: 'badge-created' },
|
||
updated: { text: 'עודכן', cls: 'badge-updated' },
|
||
skipped: { text: 'דולג', cls: 'badge-skipped' },
|
||
error: { text: 'שגיאה', cls: 'badge-error' },
|
||
would_create: { text: 'ייווצר', cls: 'badge-dry' },
|
||
}
|
||
|
||
// ── modal ──────────────────────────────────────────────────────────────────
|
||
|
||
if (!open) {
|
||
return (
|
||
<button
|
||
className="btn btn-import"
|
||
onClick={() => setOpen(true)}
|
||
disabled={!eventId}
|
||
title={!eventId ? 'בחר אירוע תחילה' : 'ייבוא אנשי קשר מקובץ CSV / JSON'}
|
||
>
|
||
📂 ייבוא קובץ
|
||
</button>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="import-overlay" onClick={(e) => e.target === e.currentTarget && handleClose()}>
|
||
<div className="import-modal" dir="rtl">
|
||
{/* Header */}
|
||
<div className="import-header">
|
||
<h2>📂 ייבוא אנשי קשר מקובץ</h2>
|
||
<button className="import-close" onClick={handleClose}>✕</button>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div className="import-body">
|
||
{/* File drop zone */}
|
||
<div
|
||
className={`drop-zone ${dragging ? 'dragging' : ''} ${file ? 'has-file' : ''}`}
|
||
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
|
||
onDragLeave={() => setDragging(false)}
|
||
onDrop={handleDrop}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".csv,.json,.xlsx"
|
||
style={{ display: 'none' }}
|
||
onChange={handleFileChange}
|
||
/>
|
||
{file ? (
|
||
<>
|
||
<span className="drop-icon">✅</span>
|
||
<p className="drop-filename">{file.name}</p>
|
||
<p className="drop-hint">לחץ להחלפת הקובץ</p>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="drop-icon">📄</span>
|
||
<p className="drop-text">גרור קובץ CSV או JSON לכאן</p>
|
||
<p className="drop-hint">או לחץ לבחירת קובץ</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Format hint */}
|
||
<div className="import-hint">
|
||
<details>
|
||
<summary>פורמטים נתמכים</summary>
|
||
<div className="hint-body">
|
||
<p><strong>CSV</strong> — כל שורה = אורח. עמודות נתמכות:</p>
|
||
<code>First Name, Last Name, Full Name, Phone, Email, RSVP, Meal, Notes, Side, Table</code>
|
||
<p><strong>JSON</strong> — מערך של אובייקטים עם אותן שדות, או <code>{`{"data":[…]}`}</code>.</p>
|
||
<p>הייבוא ניתן לביצוע כפעולת מה-Excel שיצאת: אותן עמודות שמופיעות בייצוא לאקסל.</p>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
{/* Dry-run toggle */}
|
||
<label className="dry-run-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={isDryRun}
|
||
onChange={(e) => setIsDryRun(e.target.checked)}
|
||
/>
|
||
<span>בדיקה בלבד (Dry Run) — הצג מה היה קורה ללא שמירה</span>
|
||
</label>
|
||
|
||
{/* Error */}
|
||
{error && <div className="import-error">{error}</div>}
|
||
|
||
{/* Upload button */}
|
||
{!result && (
|
||
<button
|
||
className="btn-upload"
|
||
onClick={handleUpload}
|
||
disabled={loading || !file}
|
||
>
|
||
{loading ? '⏳ מייבא…' : isDryRun ? '🔍 הרץ בדיקה' : '📥 ייבא אנשי קשר'}
|
||
</button>
|
||
)}
|
||
|
||
{/* Results */}
|
||
{result && (
|
||
<div className="import-results">
|
||
<div className={`results-banner ${result.dry_run ? 'dry' : 'live'}`}>
|
||
{result.dry_run ? '🔍 תוצאות בדיקה (לא נשמר)' : '✅ הייבוא הושלם!'}
|
||
</div>
|
||
|
||
<div className="results-stats">
|
||
<div className="stat"><span>{result.total}</span><small>סה"כ</small></div>
|
||
<div className="stat created"><span>{result.created}</span><small>{result.dry_run ? 'ייווצרו' : 'נוצרו'}</small></div>
|
||
<div className="stat updated"><span>{result.updated}</span><small>עודכנו</small></div>
|
||
<div className="stat skipped"><span>{result.skipped}</span><small>דולגו</small></div>
|
||
{result.errors > 0 && (
|
||
<div className="stat errors"><span>{result.errors}</span><small>שגיאות</small></div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Row-level table */}
|
||
{result.rows.length > 0 && (
|
||
<div className="results-table-wrap">
|
||
<table className="results-table">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>שם</th>
|
||
<th>טלפון</th>
|
||
<th>פעולה</th>
|
||
<th>הערה</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{result.rows.map((r) => {
|
||
const lbl = actionLabel[r.action] || { text: r.action, cls: '' }
|
||
return (
|
||
<tr key={r.row} className={`row-${r.action}`}>
|
||
<td>{r.row}</td>
|
||
<td>{r.name || '—'}</td>
|
||
<td dir="ltr">{r.phone || '—'}</td>
|
||
<td><span className={`badge ${lbl.cls}`}>{lbl.text}</span></td>
|
||
<td>{r.reason || ''}</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Post-result actions */}
|
||
<div className="results-actions">
|
||
{result.dry_run && (
|
||
<button
|
||
className="btn-upload"
|
||
onClick={() => { setIsDryRun(false); setResult(null) }}
|
||
>
|
||
✅ אישור — ייבא עכשיו
|
||
</button>
|
||
)}
|
||
<button className="btn-reset" onClick={reset}>
|
||
📂 ייבא קובץ חדש
|
||
</button>
|
||
<button className="btn-close-after" onClick={handleClose}>
|
||
סגור
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default ImportContacts
|