feat: install order, dependency blocking, batch install

- Sort extensions by install order (HebrewLanguage first, then deps)
- Grey out extensions with missing dependencies (show lock icon + "נדרש: X")
- Checkbox selection for batch install with progress indicator
- "בחר הכל" button to select all installable extensions
- Server returns dependencies + install_order in customer API

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 18:44:56 +00:00
parent eea011d68a
commit 22b05617d4
2 changed files with 146 additions and 13 deletions
+110 -12
View File
@@ -201,11 +201,41 @@ function CustomerDetail({ customerId }) {
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: '' })
if (!customer) return <Loader />
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 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)
@@ -224,6 +254,34 @@ function CustomerDetail({ customerId }) {
setDeploying(null)
}
const batchDeploy = async () => {
const toDeploy = sorted.filter(e => selected.has(e.id) && !e.installed_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()
@@ -265,38 +323,76 @@ function CustomerDetail({ customerId }) {
</div>
)}
{batchDeploying && (
<div className="p-4 rounded-xl mb-6 bg-blue-50 text-blue-800 text-sm font-medium flex items-center gap-3">
<Icon name="sync" className="text-base animate-spin" />
מתקין {batchProgress.current}/{batchProgress.total}: {batchProgress.module}...
</div>
)}
<section className="bg-white rounded-xl overflow-hidden">
<div className="p-6 flex justify-between items-center">
<div className="flex items-center gap-3">
<h4 className="font-bold text-slate-900">הרחבות מורשות</h4>
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest">{(customer.extensions || []).length} הרחבות</span>
{selected.size > 0 && (
<button onClick={batchDeploy} disabled={batchDeploying}
className="deep-action-gradient text-white px-5 py-1.5 rounded-lg text-xs font-bold shadow-sm active:scale-95 transition-all disabled:opacity-50">
{batchDeploying ? `מתקין ${batchProgress.current}/${batchProgress.total}...` : `התקן ${selected.size} הרחבות`}
</button>
)}
</div>
<div className="flex items-center gap-3">
{sorted.some(e => !e.installed_version && (deps[e.name] || []).every(d => installedNames.has(d))) && (
<button onClick={selectAll}
className="text-xs text-blue-600 hover:text-blue-800 font-medium">
בחר הכל
</button>
)}
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest">{exts.length} הרחבות</span>
</div>
</div>
<table className="w-full text-right border-collapse">
<thead>
<tr className="bg-slate-50/50">
<th className="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-widest">שם ההרחבה</th>
<th className="px-3 py-4 w-10"></th>
<th className="px-4 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-widest">שם ההרחבה</th>
<th className="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-widest">מותקנת</th>
<th className="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-widest">עדכון זמין</th>
<th className="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-widest text-center">פעולות</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{(customer.extensions || []).map(e => {
{sorted.map(e => {
const installed = e.installed_version
const hasUpdate = installed && e.latest_version && installed !== e.latest_version
const missingDeps = (deps[e.name] || []).filter(d => !installedNames.has(d))
const blocked = !installed && missingDeps.length > 0
return (
<tr key={e.id} className="hover:bg-slate-50/80 transition-all">
<td className="px-8 py-5">
<tr key={e.id} className={`transition-all ${blocked ? 'opacity-40' : 'hover:bg-slate-50/80'}`}>
<td className="px-3 py-5 text-center">
{!installed && !blocked && (
<input type="checkbox" checked={selected.has(e.id)}
onChange={() => toggleSelect(e.id)} disabled={batchDeploying}
className="rounded border-slate-300 text-blue-600 focus:ring-blue-500 cursor-pointer" />
)}
</td>
<td className="px-4 py-5">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${installed ? 'bg-blue-50 text-blue-700' : 'bg-slate-100 text-slate-400'}`}>
<Icon name="extension" className="text-base" />
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${installed ? 'bg-blue-50 text-blue-700' : blocked ? 'bg-slate-50 text-slate-300' : 'bg-slate-100 text-slate-400'}`}>
<Icon name={blocked ? 'lock' : 'extension'} className="text-base" />
</div>
<div>
<span className={`font-bold ${blocked ? 'text-slate-400' : 'text-slate-900'}`}>{e.name}</span>
{blocked && (
<p className="text-[10px] text-slate-400 mt-0.5">נדרש: {missingDeps.join(', ')}</p>
)}
</div>
<span className="font-bold text-slate-900">{e.name}</span>
</div>
</td>
<td className="px-8 py-5">
{installed
? <VersionBadge>v{installed}</VersionBadge>
: <span className="text-sm text-red-500 font-medium">לא מותקן</span>}
: <span className={`text-sm font-medium ${blocked ? 'text-slate-300' : 'text-red-500'}`}>לא מותקן</span>}
</td>
<td className="px-8 py-5">
{hasUpdate
@@ -305,9 +401,11 @@ function CustomerDetail({ customerId }) {
</td>
<td className="px-8 py-5">
<div className="flex items-center justify-center gap-1">
{!installed ? (
{blocked ? (
<span className="text-xs text-slate-300"></span>
) : !installed ? (
<button onClick={() => deploy(e.name, e.latest_version)}
disabled={deploying === e.name}
disabled={deploying === e.name || batchDeploying}
className="deep-action-gradient text-white px-4 py-1.5 rounded-lg text-xs font-bold shadow-sm active:scale-95 transition-all disabled:opacity-50">
{deploying === e.name ? '...' : 'התקנה'}
</button>
@@ -315,7 +413,7 @@ function CustomerDetail({ customerId }) {
<>
{hasUpdate && (
<button onClick={() => deploy(e.name, e.latest_version)}
disabled={deploying === e.name}
disabled={deploying === e.name || batchDeploying}
className="p-2 text-amber-500 hover:text-amber-700 hover:bg-amber-50 rounded-lg transition-all"
title="עדכון">
<Icon name="upgrade" className="text-lg" />
+35
View File
@@ -53,6 +53,39 @@ MODULES = {
"SmsIntegration": "sms-integration", "WhatsAppIntegration": "whatsapp-integration",
}
# Extension dependency tree and install order
DEPENDENCIES = {
'HebrewLanguage': [],
'KlearBranding': ['HebrewLanguage'],
'PhoneIsraelFormat': ['HebrewLanguage'],
'EmailDefaults': ['HebrewLanguage'],
'MpdfEngine': ['HebrewLanguage'],
'LicenseManager': ['HebrewLanguage'],
'LocalDocuments': ['HebrewLanguage'],
'GoogleIntegration': ['HebrewLanguage'],
'NextCloudIntegration': ['HebrewLanguage'],
'NetworkStorageIntegration': ['HebrewLanguage'],
'SmsIntegration': ['HebrewLanguage'],
'WhatsAppIntegration': ['HebrewLanguage'],
'GreenInvoiceBilling': ['HebrewLanguage'],
'CrmAssistant': ['HebrewLanguage'],
'OfficeAssistant': ['HebrewLanguage'],
'LegalCrm': ['HebrewLanguage'],
'NetHamishpat': ['HebrewLanguage', 'LegalCrm'],
'DigitalSignature': ['HebrewLanguage', 'LegalCrm'],
'SmartAssistant': ['HebrewLanguage', 'LegalCrm'],
}
INSTALL_ORDER = [
'HebrewLanguage',
'KlearBranding', 'PhoneIsraelFormat', 'EmailDefaults', 'MpdfEngine',
'LicenseManager', 'LocalDocuments', 'GoogleIntegration',
'NextCloudIntegration', 'NetworkStorageIntegration',
'SmsIntegration', 'WhatsAppIntegration', 'GreenInvoiceBilling',
'CrmAssistant', 'OfficeAssistant', 'LegalCrm',
'NetHamishpat', 'DigitalSignature', 'SmartAssistant',
]
SSL_CTX = ssl.create_default_context()
SSL_CTX.check_hostname = False
SSL_CTX.verify_mode = ssl.CERT_NONE
@@ -406,6 +439,8 @@ class APIHandler(BaseHTTPRequestHandler):
customer['extensions'] = query("""
SELECT e.id, e.name, e.composer_name, e.latest_version, ce.installed_version, ce.license_status, ce.auto_update, ce.installed_at
FROM customer_extensions ce JOIN extensions e ON ce.extension_id = e.id WHERE ce.customer_id = %s ORDER BY e.name""", (cid,))
customer['dependencies'] = DEPENDENCIES
customer['install_order'] = INSTALL_ORDER
self._respond(200, customer)
def _create_customer(self, body):