/* ═══════════════════════════════════════════════════════════
Main app — icons, login, overview dashboard, app shell
(rail nav + topbar + theme toggle), Root state + mount.
This is the reconstructed entry that ties every view +
modal together.
═══════════════════════════════════════════════════════════ */
(function () {
const { useState: useStateA, useEffect: useEffectA, useReducer, useRef: useRefA } = React;
const {
GUNS: GUNS_A, parseKey: parseKeyA, keyLabel: keyLabelA, fmtDatetime: fmtA,
totalAmmo: totalAmmoA, totalAmmoGuns: totalAmmoGunsA, totalAmmoBunker: totalAmmoBunkerA,
calcGroupTotal: calcGroupTotalA,
buildInit: buildInitA, applyMigrations: applyMigrationsA,
reducer: reducerA, saveState: saveStateA,
} = window;
/* ── Icons (inline stroke SVG) ──────────────────────────── */
const ICON_PATHS = {
grid: 'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z',
layers: 'M12 2 2 7l10 5 10-5zM2 17l10 5 10-5M2 12l10 5 10-5',
box: 'M21 8 12 3 3 8v8l9 5 9-5zM3 8l9 5 9-5M12 13v8',
target: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 6a6 6 0 1 0 0 12 6 6 0 0 0 0-12zM12 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4z',
swap: 'M16 3l4 4-4 4M20 7H8M8 21l-4-4 4-4M4 17h12',
ban: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM5 5l14 14',
barrel: 'M5 4h14M5 4v16M19 4v16M5 20h14M8 4v16M16 4v16',
list: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
settings: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-2.82 1.17V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c.2.61.76 1 1.4 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z',
sun: 'M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4',
moon: 'M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z',
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
menu: 'M3 12h18M3 6h18M3 18h18',
plus: 'M12 5v14M5 12h14',
cloud: 'M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z',
bolt: 'M13 2 3 14h9l-1 8 10-12h-9z',
shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
};
function Icon({ name, size = 18, stroke = 2, fill = false }) {
const d = ICON_PATHS[name] || '';
return (
);
}
/* ── Toast ──────────────────────────────────────────────── */
function useToast() {
const [toast, setToast] = useStateA(null);
const show = (msg, kind = 'ok') => { setToast({ msg, kind }); setTimeout(() => setToast(null), 2400); };
const node = toast ?
{toast.msg}
: null;
return [node, show];
}
/* ── Login screen ───────────────────────────────────────── */
const ROLE_META = {
viewer: { label: 'צפייה בלבד', color: 'var(--text-3)', icon: '👁' },
operator: { label: 'מפעיל', color: 'var(--ok)', icon: '⚡' },
admin: { label: 'מנהל', color: 'var(--accent)', icon: '🔐' },
};
function LoginScreen({ onAuthed, sessionExpired }) {
const cfg = window.APP_CONFIG || {};
const [code, setCode] = useStateA('');
const [err, setErr] = useStateA('');
const [busy, setBusy] = useStateA(false);
const [role, setRole] = useStateA(null); // show after login
const submit = async () => {
if (!code || busy) return;
setBusy(true); setErr('');
try {
const d = await window.api.login(code);
setRole(d.role);
// Brief role flash then continue
setTimeout(() => onAuthed(), 900);
}
catch { setErr('קוד שגוי — נסה שוב'); setCode(''); }
finally { setBusy(false); }
};
if (role) {
const m = ROLE_META[role] || ROLE_META.viewer;
return (
{m.icon}
{m.label}
כניסה מאושרת...
);
}
return (
{cfg.appName || 'ניהול תחמושת סוללתית'}
{cfg.appShort || 'ARTY · AMMO'} · גרסה {cfg.version || ''}
{sessionExpired && (
⏱ הסשן פג — יש להתחבר מחדש
)}
{err &&
{err}
}
setCode(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submit()}
style={{ textAlign: 'center', fontSize: '1.05rem', letterSpacing: '.12em' }} />
{cfg.roles?.hasOperator && (
👁 צפייה · ⚡ מפעיל · 🔐 מנהל
)}
אימות HMAC מאובטח בצד השרת
);
}
/* ── Overview dashboard ─────────────────────────────────── */
function StatCard({ icon, label, value, meta, accent }) {
return (
{icon}{label}
{value}
{meta && {meta}}
);
}
function categoryTotals(state) {
const gun = totalAmmoGunsA(state);
const ramsav = totalAmmoBunkerA(state);
const outGun = { shell: 0, charge: 0, fuze: 0 };
const outRamsav = { shell: 0, charge: 0, fuze: 0 };
(state.registeredTypes || []).forEach(r => {
outGun[r.category] = (outGun[r.category] || 0) + (gun[r.key] || 0);
outRamsav[r.category] = (outRamsav[r.category] || 0) + (ramsav[r.key] || 0);
});
return { gun: outGun, ramsav: outRamsav };
}
/* ── Custom domain icons (inline SVG) ───────────────────── */
function ShellIcon({ size = 16 }) {
// Artillery shell silhouette
return (
);
}
function MaintenanceIcon({ size = 16 }) {
// Wrench — cleaning/maintenance
return (
);
}
function BrokenBarrelIcon({ size = 16 }) {
// Cracked barrel — barrel wear
return (
);
}
function readinessOf(state, g) {
if (state.readiness?.red === g) return { cls: 'dot-danger', label: 'כוננות צוות אדום' };
if (state.readiness?.yellow === g) return { cls: 'dot-warn', label: 'כוננות צוות צהוב' };
return { cls: 'dot-ok', label: 'כוננות צוות ירוק' };
}
function GunMiniCard({ state, gun }) {
const regs = state.registeredTypes;
const inv = state.guns[gun] || {};
const items = regs.filter(r => (inv[r.key] || 0) > 0).sort((a, b) => (inv[b.key] || 0) - (inv[a.key] || 0));
const top = items.slice(0, 4);
const r = readinessOf(state, gun);
const bd = state.barrelData?.[gun] || {};
const totalShells = bd.totalShells || 0;
return (
{gun}
{r.label}
{top.length === 0 ?
אין מלאי : top.map(it => (
{it.label}{inv[it.key]}
))}
{items.length > 4 &&
+{items.length - 4} סוגים נוספים}
{bd.shellsSinceClean || 0} נקיון
{bd.muzzle?.count || 0} קנה
{totalShells} סה"כ
);
}
function ActivityFeed({ state }) {
const regs = state.registeredTypes;
const fires = (state.firingLog || []).slice(0, 6).map(r => ({
kind: 'fire', ts: (r.date || '') + ' ' + (r.time || ''),
title: `משימת אש ${r.missionId} — ${r.target || ''}`, sub: `צוות ${r.gun} · ${r.unit || ''}`,
}));
const moves = [...(state.transferLog || [])].slice(-6).reverse().map(r => ({
kind: 'move', ts: fmtA(r.datetime),
title: `${r.src} ← ${r.dst}`, sub: (r.items || []).map(it => `${keyLabelA(it.key, regs)}×${it.qty}`).join(' · '),
}));
const feed = [...fires, ...moves].slice(0, 8);
const meta = {
fire: { icon: , color: 'var(--danger)', soft: 'var(--danger-soft)' },
move: { icon: , color: 'var(--info)', soft: 'var(--info-soft)' },
};
return (
פעילות אחרונה
{feed.length === 0 ?
אין פעילות עדיין
: (
{feed.map((f, i) => (
{meta[f.kind].icon}
{f.title}{f.sub} · {f.ts}
))}
)}
);
}
function OverviewView({ state }) {
const cats = categoryTotals(state);
const redTeam = state.readiness?.red, yellowTeam = state.readiness?.yellow;
const totalsGun = totalAmmoGunsA(state);
const critical = (state.registeredTypes || []).filter(r => { const rl = state.redLines?.[r.key] || 0; return rl > 0 && (totalsGun[r.key] || 0) < rl; });
const groups = (state.ammoGroups || []).map(g => ({ ...g, ...calcGroupTotalA(g, state) }));
const critGroups = groups.filter(g => g.redLine > 0 && g.total < g.redLine);
return (
{(redTeam || yellowTeam || critical.length > 0 || critGroups.length > 0) && (
{redTeam &&
צוות {redTeam} כוננות צוות אדום
}
{yellowTeam &&
צוות {yellowTeam} כוננות צוות צהוב
}
{critical.length > 0 &&
⚠ {critical.length} סוגי תחמושת מתחת לקו האדום
}
{critGroups.map(g =>
⚠ קבוצה {g.label}: {g.total} / קו {g.redLine}
)}
)}
} label="פגזים קרקע" value={cats.gun.shell} meta="ערום צוותים" accent />
} label="פגזים רמסע" value={cats.ramsav.shell} meta="משטחים/מחסן" />
} label='חנ"ה קרקע' value={cats.gun.charge} />
} label="מרעומים קרקע" value={cats.gun.fuze} />
} label="משימות אש" value={(state.firingLog || []).length} />
} label="פינת פסולים" value={(state.quarantineLog || []).length} />
{/* Ammo groups summary — only shown when groups exist */}
{groups.length > 0 && (
📦 קבוצות תחמושת
{groups.map(g => {
const crit = g.redLine > 0 && g.total < g.redLine;
const pct = g.redLine > 0 ? Math.min(100, Math.round(g.total / g.redLine * 100)) : null;
return (
{g.label}
{crit && ⚠ מתחת לקו}
{g.total}
סה"כ
{g.gunTotal}
קרקע
{g.bunkerTotal}
רמסע
{g.redLine > 0 && (
{g.redLine}
קו אדום
)}
{pct !== null && (
)}
);
})}
)}
{/* Bottom section fills remaining height on PC */}
מצב צוותים{GUNS_A.length} צוותים
);
}
/* ── App shell ──────────────────────────────────────────── */
const NAV = [
{ sec: 'ראשי', items: [{ id: 'overview', label: 'סקירה כללית', icon: 'grid' }] },
{ sec: 'מלאי', items: [
{ id: 'teams', label: 'מטריצת צוותים', icon: 'layers' },
{ id: 'bunker', label: 'מחסן ומשטחים', icon: 'box' },
{ id: 'barrel', label: 'בלאי קנה', icon: 'barrel' },
] },
{ sec: 'תיעוד', items: [
{ id: 'missions', label: 'יומן משימות אש', icon: 'target' },
{ id: 'transfers', label: 'יומן תנועות', icon: 'swap' },
{ id: 'quarantine', label: 'פינת פסולים', icon: 'ban' },
] },
{ sec: 'מערכת', items: [{ id: 'catalog', label: 'קטלוג תחמושת', icon: 'settings' }] },
];
const VIEW_TITLES = {
overview: 'סקירה כללית', teams: 'מטריצת מלאי צוותים', bunker: 'מחסן ומשטחים', barrel: 'בלאי קנה',
missions: 'יומן משימות אש', transfers: 'יומן תנועות מלאי', quarantine: 'פינת פסולים', catalog: 'קטלוג תחמושת מרכזי',
};
function App({ state, dispatch, loadingCloud, theme, onToggleTheme, onLogout }) {
const [view, setView] = useStateA('overview');
const [drawer, setDrawer] = useStateA(false);
const [modal, setModal] = useStateA(null);
const [adminModal, setAdminModal] = useStateA(null);
const [resetConfirm, setResetConfirm] = useStateA(false);
const [importFile, setImportFile] = useStateA(null);
const [isOnline, setIsOnline] = useStateA(() => window.api.isOnline());
const [offlinePending, setOfflinePending] = useStateA(() => window.api.hasOfflineQueue());
const [syncing, setSyncing] = useStateA(false);
const [toastNode, showToast] = useToast();
const counts = {
quarantine: (state.quarantineLog || []).length,
bunker: (state.bunker || []).length,
missions: (state.firingLog || []).length,
};
// Listen for sync conflict events from api.js
useEffectA(() => {
const handler = () => showToast('⚠ סנכרון: נמצאה גרסה חדשה יותר — התצוגה עודכנה');
window.addEventListener('arty:sync-conflict', handler);
return () => window.removeEventListener('arty:sync-conflict', handler);
}, [showToast]);
// Listen for online/offline status changes
useEffectA(() => {
const onStatus = (e) => setIsOnline(e.detail.online);
const onQueued = () => setOfflinePending(true);
const onBackOnline = () => {
// Back online and there's a queue — show a prompt but don't auto-sync
setIsOnline(true);
showToast('חיבור שוחזר — לחץ "סנכרן שינויים" לשמירה');
};
window.addEventListener('arty:online-status', onStatus);
window.addEventListener('arty:queued-offline', onQueued);
window.addEventListener('arty:back-online', onBackOnline);
return () => {
window.removeEventListener('arty:online-status', onStatus);
window.removeEventListener('arty:queued-offline', onQueued);
window.removeEventListener('arty:back-online', onBackOnline);
};
}, [showToast]);
const go = id => { setView(id); setDrawer(false); };
const addPallet = (ammo, newTypes) => { dispatch({ type: 'ADD_PALLET', ammo, newTypes }); setModal(null); showToast('המשטח נקלט בהצלחה'); };
// ── Export (admin only — uses cached token) ──────────────
const handleExport = async () => {
try {
const d = await window.api.exportState();
const blob = new Blob([JSON.stringify(d, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `arty-ammo-backup-${new Date().toISOString().slice(0, 10)}.json`; a.click();
URL.revokeObjectURL(url);
showToast('ייצוא הצליח');
} catch (e) {
showToast('שגיאה בייצוא — בדוק הרשאות', 'error');
}
};
// ── Import (admin-gated) ──────────────────────────────────
const handleImportFile = (e) => {
const file = e.target.files?.[0]; if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
try { setImportFile(JSON.parse(ev.target.result)); setAdminModal('import'); }
catch { showToast('קובץ לא תקין', 'error'); }
};
reader.readAsText(file);
};
const handleImport = async () => {
if (!importFile) return;
const payload = importFile.data || importFile;
const ver = importFile.version || 0;
try {
await window.api.importState(payload, ver);
dispatch({ type: 'IMPORT_STATE', payload: applyMigrationsA ? applyMigrationsA(payload) : payload });
setAdminModal(null); setImportFile(null); showToast('ייבוא הצליח — המערכת עודכנה');
} catch { showToast('שגיאה בייבוא — בדוק הרשאות', 'error'); }
};
const handleResetStep1 = () => {
// Admin already authenticated — just confirm
setAdminModal(null); setResetConfirm(true);
};
const handleResetConfirmed = async () => {
const freshState = buildInitA();
try {
const r = await fetch(window.api.base() + '/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + window.api._token },
body: JSON.stringify({ data: freshState, version: null }),
});
if (!r.ok) throw new Error('save failed: ' + r.status);
window.api._version = 1;
} catch (e) {
console.error('[reset] DB write failed:', e.message);
showToast('שגיאה בכתיבה ל-DB — נסה שוב', 'error');
setResetConfirm(false); return;
}
dispatch({ type: 'RESET_STATE' });
try { localStorage.clear(); } catch {}
setResetConfirm(false);
showToast('המערכת אופסה בהצלחה — כל הנתונים נמחקו');
};
// ── Manual sync of offline queue ─────────────────────────
const handleSync = async () => {
if (syncing || !isOnline) return;
setSyncing(true);
try {
const result = await window.api.syncOfflineQueue();
if (result.synced) {
setOfflinePending(false);
showToast('✓ השינויים סונכרנו בהצלחה');
} else if (result.reason === 'still_offline') {
showToast('עדיין אין חיבור — נסה שוב', 'error');
} else if (result.reason === 'conflict_server_wins') {
setOfflinePending(false);
showToast('⚠ קונפליקט — המערכת עודכנה מהשרת');
} else {
showToast('שגיאה בסנכרון — נסה שוב', 'error');
}
} finally {
setSyncing(false);
}
};
return (
{/* ── Offline banner (topbar overlay) ── */}
{!isOnline && (
⚠ אין חיבור לרשת — שינויים נשמרים מקומית ויסונכרנו בחזרת החיבור
)}
{/* ── Rail ── */}
setDrawer(false)} />
{/* ── Main ── */}
{VIEW_TITLES[view]}
משימת אש אחרונה: {state.lastFireMissionId || '—'}
{window.api?.canEdit() && <>
>}
{window.api?.isViewer() && (
👁 מצב צפייה בלבד
)}
{view === 'overview' && }
{view === 'teams' && }
{view === 'bunker' && setModal('pallet')} />}
{view === 'barrel' && }
{view === 'missions' && }
{view === 'transfers' && }
{view === 'quarantine' && }
{view === 'catalog' && }
{/* ── Modals ── */}
{modal === 'fire' &&
setModal(null)} />}
{modal === 'pallet' && setModal(null)} onSubmit={addPallet} />}
{modal === 'transfer' && setModal(null)} />}
{modal === 'stock' && setModal(null)} />}
{/* ── Admin modals — no password re-entry (token cached in session) ── */}
{adminModal === 'import' && importFile && (
ייבוא יחליף את כל נתוני המערכת. הקלד RESET לאישור.}
onConfirm={handleImport}
onCancel={() => { setAdminModal(null); setImportFile(null); }}
/>
)}
{adminModal === 'reset' && setAdminModal(null)} />}
{resetConfirm && setResetConfirm(false)} />}
{toastNode}
);
}
/* ── Admin password modal ────────────────────────────────── */
function AdminGate({ title, onSuccess, onCancel }) {
const [code, setCode] = useStateA('');
const [err, setErr] = useStateA('');
const [working, setWorking] = useStateA(false);
const attempt = async () => {
if (!code || working) return;
setWorking(true); setErr('');
try { await onSuccess(code); }
catch { setErr('קוד מנהל שגוי ⛔'); setCode(''); }
finally { setWorking(false); }
};
return (
{ if (e.target === e.currentTarget) onCancel(); }}>
🔐 {title}
נדרש קוד מנהל (ADMIN_PASSWORD)
{err &&
{err}
}
setCode(e.target.value)}
onKeyDown={e => e.key === 'Enter' && attempt()} placeholder="הקלד קוד מנהל..." autoFocus
style={{ textAlign: 'center', fontSize: '1rem', letterSpacing: '.15em' }} />
);
}
/* ── Full reset second-confirmation modal ────────────────── */
function ResetConfirmModal({ onConfirm, onCancel, title, message }) {
const [typed, setTyped] = useStateA('');
const ok = typed.trim() === 'RESET';
return (
{ if (e.target === e.currentTarget) onCancel(); }}>
⚠ {title || 'אישור סופי — איפוס מלא'}
{message || (<>כל הנתונים יימחקו לצמיתות מה-DB.
מלאי, משימות, תנועות, פסולים, בלאי קנים, קטלוג.
פעולה זו אינה הפיכה.>)}
setTyped(e.target.value)}
placeholder="RESET" autoFocus style={{ textAlign: 'center', fontFamily: 'monospace', fontSize: '1.1rem' }} />
);
}
/* ── Root: config + auth + state wiring ─────────────────── */
function AuthedApp({ theme, onToggleTheme, onLogout }) {
const [loadingCloud, setLoadingCloud] = useStateA(true);
const [state, dispatch] = useReducer(reducerA, null, () => buildInitA());
useEffectA(() => { window.__appDispatch = dispatch; return () => { window.__appDispatch = null; }; }, [dispatch]);
const cloudLoaded = useRefA(false);
const firstMount = useRefA(true);
useEffectA(() => {
window.__syncConflictToast = () => window.dispatchEvent(new CustomEvent('arty:sync-conflict'));
return () => { window.__syncConflictToast = null; };
}, []);
useEffectA(() => {
let alive = true;
(async () => {
try {
const r = await window.api.loadState();
if (alive && r && r.data) dispatch({ type: 'IMPORT_STATE', payload: applyMigrationsA(r.data) });
} catch {}
finally {
if (alive) {
cloudLoaded.current = true;
setLoadingCloud(false);
window.api.startPolling(30000);
}
}
})();
return () => { alive = false; window.api.stopPolling(); };
}, []);
useEffectA(() => {
if (firstMount.current) { firstMount.current = false; return; }
if (!cloudLoaded.current) return;
// Only operators and admins save — viewers are read-only
if (window.api.canEdit()) saveStateA(state);
}, [state]);
return
;
}
function Root() {
const [ready, setReady] = useStateA(false);
const [authed, setAuthed] = useStateA(false);
const [sessionExpired, setSessionExpired] = useStateA(false);
const [theme, setTheme] = useStateA(() => { try { return localStorage.getItem('arty_theme') || 'dark'; } catch { return 'dark'; } });
useEffectA(() => { document.documentElement.setAttribute('data-theme', theme); try { localStorage.setItem('arty_theme', theme); } catch {} }, [theme]);
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
useEffectA(() => {
(async () => {
try { await window.loadConfig(); } catch {}
const session = window.api.restoreSession();
if (session) setAuthed(true);
setReady(true);
})();
}, []);
// Handle midnight token expiry — show a "session expired, please log in again" notice
useEffectA(() => {
const handler = () => { setSessionExpired(true); setAuthed(false); };
window.addEventListener('arty:session-expired', handler);
return () => window.removeEventListener('arty:session-expired', handler);
}, []);
const logout = () => {
window.api.stopPolling();
window.api.logout();
try { localStorage.clear(); } catch {}
setAuthed(false);
setSessionExpired(false);
};
if (!ready) return
;
if (!authed) return
{ setAuthed(true); setSessionExpired(false); }} sessionExpired={sessionExpired} />;
return ;
}
window.App = App;
window.Root = Root;
ReactDOM.createRoot(document.getElementById('root')).render();
})();