import { useState, useEffect, useCallback } from 'react' const API = '/api' function useFetch(url) { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const reload = useCallback(() => { setLoading(true) fetch(url).then(r => r.json()).then(d => { setData(d); setLoading(false) }) .catch(() => setLoading(false)) }, [url]) useEffect(() => { reload() }, [reload]) return { data, loading, reload } } // --- Icon helper --- function Icon({ name, className = '' }) { return {name} } // --- Stats Cards --- function StatsBar() { const { data } = useFetch(`${API}/stats`) if (!data) return null const items = [ { label: 'הרחבות פעילות', value: data.total_extensions, icon: 'extension', color: 'blue' }, { label: 'לקוחות', value: data.total_customers, icon: 'group', color: 'slate' }, { label: 'רישיונות', value: data.total_licenses, icon: 'key', color: 'slate' }, { label: 'פריסות מוצלחות', value: data.successful_deploys, icon: 'check_circle', color: 'emerald', border: true }, { label: 'כשלונות', value: data.failed_deploys, icon: 'error', color: 'red', border: true }, ] return (
{items.map(s => (

{s.value}

{s.label}

))}
) } // --- Extensions Table --- function ExtensionsTab() { const { data: extensions } = useFetch(`${API}/extensions`) if (!extensions) return return (

רשימת הרחבות

כל ההרחבות הזמינות במערכת

{extensions.map(e => ( ))}
שם ההרחבה Composer גרסה אחרונה לקוחות גרסאות
{e.name}
{e.composer_name} {e.latest_version || '—'} {e.customer_count} {e.version_count}
) } // --- Customers Tab --- function CustomersTab() { const { data: customers, reload } = useFetch(`${API}/customers`) const [selected, setSelected] = useState(null) const [showForm, setShowForm] = useState(false) const deleteCustomer = async (id) => { await fetch(`${API}/customers/${id}`, { method: 'DELETE' }) if (selected === id) setSelected(null) reload() } if (!customers) return return (

לקוחות ({customers.length})

{showForm && { setShowForm(false); reload() }} onCancel={() => setShowForm(false)} />}
{customers.map(c => (
setSelected(c.id)} className={`bg-white p-4 rounded-xl cursor-pointer transition-all ${selected === c.id ? 'ring-2 ring-blue-500 bg-blue-50/30' : 'hover:bg-slate-50'}`}>

{c.name}

{c.espocrm_url}

{c.installed_count || 0}/{c.extension_count} מותקנות {c.is_active ? 'פעיל' : 'לא פעיל'}
))}
{selected ? deleteCustomer(selected)} /> : (

בחר לקוח מהרשימה

)}
) } function CustomerForm({ onDone, onCancel, customer }) { const isEdit = !!customer const [form, setForm] = useState({ name: customer?.name || '', email: customer?.email || '', espocrm_url: customer?.espocrm_url || '', espocrm_api_key: '' }) const [authUser, setAuthUser] = useState('') const [authPass, setAuthPass] = useState('') const updateAuthFromCredentials = (user, pass) => { setAuthUser(user) setAuthPass(pass) if (user && pass) { setForm(f => ({ ...f, espocrm_api_key: btoa(user + ':' + pass) })) } else { setForm(f => ({ ...f, espocrm_api_key: '' })) } } const [error, setError] = useState('') const submit = async () => { if (!form.name.trim()) { setError('שם לקוח הוא שדה חובה'); return } if (!form.espocrm_url.trim()) { setError('EspoCRM URL הוא שדה חובה'); return } if (!isEdit && !form.espocrm_api_key) { setError('יש להזין שם משתמש וסיסמה'); return } setError('') const payload = { ...form } if (!payload.espocrm_api_key) delete payload.espocrm_api_key const url = isEdit ? `${API}/customers/${customer.id}` : `${API}/customers` await fetch(url, { method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) onDone() } const inputClass = "w-full bg-slate-50 border-none rounded-xl px-4 py-2.5 text-sm focus:ring-2 focus:ring-blue-500" return (
setForm({ ...form, name: e.target.value })} className={`${inputClass} text-right`} /> setForm({ ...form, email: e.target.value })} className={inputClass} dir="ltr" /> setForm({ ...form, espocrm_url: e.target.value })} className={inputClass} dir="ltr" />

פרטי התחברות ל-EspoCRM

updateAuthFromCredentials(e.target.value, authPass)} className="bg-white border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> updateAuthFromCredentials(authUser, e.target.value)} className="bg-white border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
{form.espocrm_api_key && (

Espo-Authorization: {form.espocrm_api_key}

)} {isEdit && !form.espocrm_api_key && (

השאר ריק כדי לא לשנות

)}
{error &&

{error}

}
) } function CustomerDetail({ customerId, onDelete }) { const { data: customer, reload } = useFetch(`${API}/customers/${customerId}`) const { data: allExtensions } = useFetch(`${API}/extensions`) const [deploying, setDeploying] = useState(null) const [deployResult, setDeployResult] = useState(null) const [editing, setEditing] = useState(false) const [selected, setSelected] = useState(new Set()) const [batchDeploying, setBatchDeploying] = useState(false) const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, module: '' }) const [syncing, setSyncing] = useState(false) const syncAndReload = async () => { setSyncing(true) setDeployResult(null) try { const r = await fetch('/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ customer_id: customerId }) }) const result = await r.json() const custResult = result.results?.find(r => r.customer_id === customerId || r.status === 'error') if (custResult?.status === 'error') { setDeployResult({ module: 'סנכרון', status: 'error', message: custResult.message }) } } catch (e) { setDeployResult({ module: 'סנכרון', status: 'error', message: `שגיאת רשת: ${e.message}` }) } reload() setSyncing(false) } if (!customer) return const assignedIds = new Set((customer.extensions || []).map(e => e.id)) const available = (allExtensions || []).filter(e => !assignedIds.has(e.id)) const order = customer.install_order || [] const deps = customer.dependencies || {} const exts = customer.extensions || [] const installedNames = new Set(exts.filter(e => e.installed_version).map(e => e.name)) const sorted = [...exts].sort((a, b) => { const aInstalled = a.installed_version ? 0 : 1 const bInstalled = b.installed_version ? 0 : 1 if (aInstalled !== bInstalled) return aInstalled - bInstalled const ia = order.indexOf(a.name) const ib = order.indexOf(b.name) return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib) }) const toggleSelect = (id) => { setSelected(prev => { const next = new Set(prev) next.has(id) ? next.delete(id) : next.add(id) return next }) } const selectAll = () => { const installable = sorted.filter(e => { if (e.installed_version) return false const missing = (deps[e.name] || []).filter(d => !installedNames.has(d)) return missing.length === 0 }) setSelected(new Set(installable.map(e => e.id))) } const deploy = async (module, version) => { setDeploying(module) setDeployResult(null) try { const r = await fetch(`${API}/deploy`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ customer_id: customerId, module, version }) }) const result = await r.json() setDeployResult({ module, ...result }) reload() } catch (e) { setDeployResult({ module, status: 'error', message: e.message }) } setDeploying(null) } const batchDeploy = async () => { const toDeploy = sorted.filter(e => selected.has(e.id) && !e.installed_version && e.latest_version) setBatchDeploying(true) setBatchProgress({ current: 0, total: toDeploy.length, module: '' }) const results = [] for (let i = 0; i < toDeploy.length; i++) { const e = toDeploy[i] setBatchProgress({ current: i + 1, total: toDeploy.length, module: e.name }) try { const r = await fetch(`${API}/deploy`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ customer_id: customerId, module: e.name, version: e.latest_version }) }) const result = await r.json() results.push({ module: e.name, ...result }) } catch (err) { results.push({ module: e.name, status: 'error', message: err.message }) } } const ok = results.filter(r => r.status === 'ok').length const fail = results.filter(r => r.status !== 'ok').length setDeployResult({ module: `${ok} הרחבות`, status: fail === 0 ? 'ok' : 'error', message: fail > 0 ? `${ok} הצליחו, ${fail} נכשלו` : undefined }) setBatchDeploying(false) setSelected(new Set()) reload() } const remove = async (extId) => { await fetch(`${API}/customers/${customerId}/extensions/${extId}`, { method: 'DELETE' }) reload() } const assign = async (extId) => { await fetch(`${API}/customers/${customerId}/extensions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ extension_id: extId }) }) reload() } return (
{editing ? ( { setEditing(false); reload() }} onCancel={() => setEditing(false)} /> ) : (

{customer.name}

{customer.espocrm_url}

)} {deployResult && (
{deployResult.module}: {deployResult.status === 'ok' ? 'הותקן בהצלחה!' : 'נכשל'}
{deployResult.status !== 'ok' && deployResult.message && (
{deployResult.message}
{deployResult.step &&
שלב: {({find_release:'חיפוש גרסה',find_zip:'חיפוש קובץ ZIP',download_zip:'הורדת ZIP',upload:'העלאה ל-EspoCRM',install:'התקנה',rebuild:'בנייה מחדש'})[deployResult.step] || deployResult.step}
} {deployResult.code &&
קוד שגיאה: {deployResult.code}{deployResult.code === 401 ? ' (שם משתמש/סיסמה שגויים)' : deployResult.code === 403 ? ' (אין הרשאה)' : deployResult.code === 404 ? ' (לא נמצא)' : deployResult.code === 500 ? ' (שגיאת שרת)' : ''}
}
)}
)} {batchDeploying && (
מתקין {batchProgress.current}/{batchProgress.total}: {batchProgress.module}...
)}

הרחבות מורשות

{selected.size > 0 && ( )}
{sorted.some(e => !e.installed_version && (deps[e.name] || []).every(d => installedNames.has(d))) && ( )} {exts.length} הרחבות
{sorted.map(e => { const installed = e.installed_version const semverGt = (a, b) => { const pa = a.split('.').map(Number), pb = b.split('.').map(Number); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { if ((pa[i]||0) > (pb[i]||0)) return true; if ((pa[i]||0) < (pb[i]||0)) return false; } return false } const hasUpdate = installed && e.latest_version && semverGt(e.latest_version, installed) const missingDeps = (deps[e.name] || []).filter(d => !installedNames.has(d)) const blocked = !installed && missingDeps.length > 0 return ( ) })}
שם ההרחבה מותקנת עדכון זמין פעולות
{!installed && !blocked && ( toggleSelect(e.id)} disabled={batchDeploying} className="rounded border-slate-300 text-blue-600 focus:ring-blue-500 cursor-pointer" /> )}
{e.name} {blocked && (

נדרש: {missingDeps.join(', ')}

)}
{installed ? v{installed} : לא מותקן} {hasUpdate ? v{e.latest_version} : }
{blocked ? ( ) : !installed && !e.latest_version ? ( אין גרסה ) : !installed ? ( ) : ( <> {hasUpdate && ( )} )}
{/* Extensions are auto-assigned on customer creation */}
) } // --- Deployments Tab --- function DeploymentsTab() { const { data: deployments } = useFetch(`${API}/deployments?limit=50`) if (!deployments) return return (

לוג פריסות

היסטוריית כל הפריסות במערכת

{deployments.map(d => ( ))}
לקוח הרחבה גרסה סטטוס סוג זמן
{d.customer_name}
{d.extension_name}
{d.version} {d.status === 'ok' ? 'הצלחה' : 'שגיאה'} {d.status !== 'ok' && d.error_message && (
{d.error_message}
)}
{d.trigger_type === 'auto' ? 'אוטומטי' : 'ידני'} {d.started_at?.slice(0, 16).replace('T', ' ')}
) } // --- Shared Components --- function VersionBadge({ children }) { return {children} } function Loader() { return
} // --- Main App --- export default function App() { const [tab, setTab] = useState('customers') const tabs = [ { id: 'extensions', label: 'הרחבות', icon: 'extension' }, { id: 'customers', label: 'לקוחות', icon: 'group' }, { id: 'deployments', label: 'פריסות', icon: 'analytics' }, ] return (
Extension Platform
{tab === 'extensions' && } {tab === 'customers' && } {tab === 'deployments' && }
) }