341 lines
14 KiB
React
341 lines
14 KiB
React
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 }
|
|
}
|
|
|
|
// --- Stats Bar ---
|
|
function StatsBar() {
|
|
const { data } = useFetch(`${API}/stats`)
|
|
if (!data) return null
|
|
const items = [
|
|
{ label: 'הרחבות', value: data.total_extensions, color: 'bg-blue-500' },
|
|
{ label: 'לקוחות', value: data.total_customers, color: 'bg-green-500' },
|
|
{ label: 'רישיונות', value: data.total_licenses, color: 'bg-purple-500' },
|
|
{ label: 'פריסות מוצלחות', value: data.successful_deploys, color: 'bg-emerald-500' },
|
|
{ label: 'כשלונות', value: data.failed_deploys, color: 'bg-red-500' },
|
|
]
|
|
return (
|
|
<div className="grid grid-cols-5 gap-4 mb-8">
|
|
{items.map(s => (
|
|
<div key={s.label} className="bg-white rounded-lg shadow p-4 text-center">
|
|
<div className={`text-3xl font-bold ${s.color.replace('bg-', 'text-')}`}>{s.value}</div>
|
|
<div className="text-sm text-gray-500 mt-1">{s.label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Extensions Table ---
|
|
function ExtensionsTab() {
|
|
const { data: extensions } = useFetch(`${API}/extensions`)
|
|
if (!extensions) return <Loader />
|
|
return (
|
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
<table className="w-full text-right">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-sm font-medium text-gray-500">שם</th>
|
|
<th className="px-4 py-3 text-sm font-medium text-gray-500">Composer</th>
|
|
<th className="px-4 py-3 text-sm font-medium text-gray-500">גרסה אחרונה</th>
|
|
<th className="px-4 py-3 text-sm font-medium text-gray-500">לקוחות</th>
|
|
<th className="px-4 py-3 text-sm font-medium text-gray-500">גרסאות</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200">
|
|
{extensions.map(e => (
|
|
<tr key={e.id} className="hover:bg-gray-50">
|
|
<td className="px-4 py-3 font-medium">{e.name}</td>
|
|
<td className="px-4 py-3 text-sm text-gray-500 font-mono">{e.composer_name}</td>
|
|
<td className="px-4 py-3"><Badge>{e.latest_version || '—'}</Badge></td>
|
|
<td className="px-4 py-3 text-center">{e.customer_count}</td>
|
|
<td className="px-4 py-3 text-center">{e.version_count}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Customers Tab ---
|
|
function CustomersTab() {
|
|
const { data: customers, reload } = useFetch(`${API}/customers`)
|
|
const [selected, setSelected] = useState(null)
|
|
const [showForm, setShowForm] = useState(false)
|
|
|
|
if (!customers) return <Loader />
|
|
return (
|
|
<div className="flex gap-6">
|
|
<div className="w-1/2">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="text-lg font-semibold">לקוחות ({customers.length})</h3>
|
|
<button onClick={() => setShowForm(!showForm)}
|
|
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 text-sm">
|
|
+ לקוח חדש
|
|
</button>
|
|
</div>
|
|
{showForm && <CustomerForm onDone={() => { setShowForm(false); reload() }} />}
|
|
<div className="bg-white rounded-lg shadow divide-y">
|
|
{customers.map(c => (
|
|
<div key={c.id} onClick={() => setSelected(c.id)}
|
|
className={`p-4 cursor-pointer hover:bg-gray-50 ${selected === c.id ? 'bg-blue-50 border-r-4 border-blue-500' : ''}`}>
|
|
<div className="font-medium">{c.name}</div>
|
|
<div className="text-sm text-gray-500">{c.espocrm_url}</div>
|
|
<div className="text-xs text-gray-400 mt-1">
|
|
{c.extension_count} הרחבות • {c.is_active ? '🟢 פעיל' : '🔴 לא פעיל'}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="w-1/2">
|
|
{selected && <CustomerDetail customerId={selected} />}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CustomerForm({ onDone }) {
|
|
const [form, setForm] = useState({ name: '', email: '', espocrm_url: '', espocrm_api_key: '' })
|
|
const submit = async () => {
|
|
await fetch(`${API}/customers`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form)
|
|
})
|
|
onDone()
|
|
}
|
|
return (
|
|
<div className="bg-white rounded-lg shadow p-4 mb-4 space-y-3">
|
|
<input placeholder="שם לקוח" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
|
|
className="w-full border rounded px-3 py-2 text-right" />
|
|
<input placeholder="אימייל" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })}
|
|
className="w-full border rounded px-3 py-2 text-right" dir="ltr" />
|
|
<input placeholder="EspoCRM URL" value={form.espocrm_url} onChange={e => setForm({ ...form, espocrm_url: e.target.value })}
|
|
className="w-full border rounded px-3 py-2" dir="ltr" />
|
|
<input placeholder="Espo-Authorization (base64)" value={form.espocrm_api_key}
|
|
onChange={e => setForm({ ...form, espocrm_api_key: e.target.value })}
|
|
className="w-full border rounded px-3 py-2 font-mono text-sm" dir="ltr" />
|
|
<button onClick={submit} className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 w-full">
|
|
שמור
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CustomerDetail({ customerId }) {
|
|
const { data: customer, reload } = useFetch(`${API}/customers/${customerId}`)
|
|
const { data: allExtensions } = useFetch(`${API}/extensions`)
|
|
const [deploying, setDeploying] = useState(null)
|
|
const [deployResult, setDeployResult] = useState(null)
|
|
|
|
if (!customer) return <Loader />
|
|
|
|
const assignedIds = new Set((customer.extensions || []).map(e => e.id))
|
|
const available = (allExtensions || []).filter(e => !assignedIds.has(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 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 (
|
|
<div>
|
|
<h3 className="text-lg font-semibold mb-2">{customer.name}</h3>
|
|
<div className="text-sm text-gray-500 mb-4" dir="ltr">{customer.espocrm_url}</div>
|
|
|
|
{deployResult && (
|
|
<div className={`p-3 rounded mb-4 text-sm ${deployResult.status === 'ok' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
|
{deployResult.module}: {deployResult.status === 'ok' ? 'הותקן בהצלחה!' : `שגיאה: ${deployResult.message}`}
|
|
</div>
|
|
)}
|
|
|
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
<table className="w-full text-right">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-sm font-medium text-gray-500">שם ההרחבה</th>
|
|
<th className="px-4 py-3 text-sm font-medium text-gray-500">מותקנת</th>
|
|
<th className="px-4 py-3 text-sm font-medium text-gray-500">עדכון זמין</th>
|
|
<th className="px-4 py-3 text-sm font-medium text-gray-500">פעולות</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200">
|
|
{(customer.extensions || []).map(e => {
|
|
const installed = e.installed_version
|
|
const hasUpdate = installed && e.latest_version && installed !== e.latest_version
|
|
return (
|
|
<tr key={e.id} className="hover:bg-gray-50">
|
|
<td className="px-4 py-3 font-medium">{e.name}</td>
|
|
<td className="px-4 py-3">
|
|
{installed
|
|
? <Badge>v{installed}</Badge>
|
|
: <span className="text-sm text-red-500">לא מותקן</span>}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{hasUpdate
|
|
? <span className="text-sm font-medium text-amber-600">v{e.latest_version}</span>
|
|
: <span className="text-sm text-gray-300">—</span>}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex gap-2 justify-end">
|
|
{!installed ? (
|
|
<button onClick={() => deploy(e.name, e.latest_version)}
|
|
disabled={deploying === e.name}
|
|
className="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600 disabled:opacity-50">
|
|
{deploying === e.name ? '...' : 'התקנה'}
|
|
</button>
|
|
) : (
|
|
<>
|
|
{hasUpdate && (
|
|
<button onClick={() => deploy(e.name, e.latest_version)}
|
|
disabled={deploying === e.name}
|
|
className="bg-amber-500 text-white px-3 py-1 rounded text-sm hover:bg-amber-600 disabled:opacity-50">
|
|
{deploying === e.name ? '...' : 'עדכון'}
|
|
</button>
|
|
)}
|
|
<button onClick={() => remove(e.id)}
|
|
className="bg-red-500 text-white px-3 py-1 rounded text-sm hover:bg-red-600">
|
|
הסרה
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{available.length > 0 && (
|
|
<div className="mt-4">
|
|
<h4 className="text-sm font-medium text-gray-500 mb-2">הוסף הרחבה:</h4>
|
|
<select onChange={e => e.target.value && assign(parseInt(e.target.value))}
|
|
className="border rounded px-3 py-2 w-full">
|
|
<option value="">בחר הרחבה...</option>
|
|
{available.map(e => <option key={e.id} value={e.id}>{e.name} v{e.latest_version}</option>)}
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Deployments Tab ---
|
|
function DeploymentsTab() {
|
|
const { data: deployments } = useFetch(`${API}/deployments?limit=50`)
|
|
if (!deployments) return <Loader />
|
|
return (
|
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
<table className="w-full text-right">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-sm font-medium text-gray-500">לקוח</th>
|
|
<th className="px-4 py-3 text-sm font-medium text-gray-500">הרחבה</th>
|
|
<th className="px-4 py-3 text-sm font-medium text-gray-500">גרסה</th>
|
|
<th className="px-4 py-3 text-sm font-medium text-gray-500">סטטוס</th>
|
|
<th className="px-4 py-3 text-sm font-medium text-gray-500">סוג</th>
|
|
<th className="px-4 py-3 text-sm font-medium text-gray-500">זמן</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200">
|
|
{deployments.map(d => (
|
|
<tr key={d.id} className="hover:bg-gray-50">
|
|
<td className="px-4 py-3">{d.customer_name}</td>
|
|
<td className="px-4 py-3 font-medium">{d.extension_name}</td>
|
|
<td className="px-4 py-3"><Badge>{d.version}</Badge></td>
|
|
<td className="px-4 py-3">
|
|
<span className={`px-2 py-1 rounded text-xs ${d.status === 'ok' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
|
{d.status === 'ok' ? 'הצלחה' : 'שגיאה'}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-500">{d.trigger_type === 'auto' ? 'אוטומטי' : 'ידני'}</td>
|
|
<td className="px-4 py-3 text-sm text-gray-400" dir="ltr">{d.started_at?.slice(0, 19)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Shared Components ---
|
|
function Badge({ children }) {
|
|
return <span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded text-sm font-mono">{children}</span>
|
|
}
|
|
|
|
function Loader() {
|
|
return <div className="text-center py-8 text-gray-400">טוען...</div>
|
|
}
|
|
|
|
// --- Main App ---
|
|
export default function App() {
|
|
const [tab, setTab] = useState('extensions')
|
|
const tabs = [
|
|
{ id: 'extensions', label: 'הרחבות' },
|
|
{ id: 'customers', label: 'לקוחות' },
|
|
{ id: 'deployments', label: 'פריסות' },
|
|
]
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-100" dir="rtl">
|
|
<header className="bg-white shadow-sm">
|
|
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
<h1 className="text-xl font-bold text-gray-800">Extension Platform</h1>
|
|
<nav className="flex gap-2">
|
|
{tabs.map(t => (
|
|
<button key={t.id} onClick={() => setTab(t.id)}
|
|
className={`px-4 py-2 rounded text-sm font-medium ${tab === t.id ? 'bg-blue-500 text-white' : 'text-gray-600 hover:bg-gray-100'}`}>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
<main className="max-w-7xl mx-auto px-6 py-6">
|
|
<StatsBar />
|
|
{tab === 'extensions' && <ExtensionsTab />}
|
|
{tab === 'customers' && <CustomersTab />}
|
|
{tab === 'deployments' && <DeploymentsTab />}
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|