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 (
)
}
// --- Extensions Table ---
function ExtensionsTab() {
const { data: extensions } = useFetch(`${API}/extensions`)
if (!extensions) return
return (
רשימת הרחבות
כל ההרחבות הזמינות במערכת
| שם ההרחבה |
Composer |
גרסה אחרונה |
לקוחות |
גרסאות |
{extensions.map(e => (
|
|
{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.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 (
)
}
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.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 (
{tab === 'extensions' && }
{tab === 'customers' && }
{tab === 'deployments' && }
)
}