Files
tripz-llc/dashboards/superadmin-web/index.html
T
Hamza-AyedandClaude Fable 5 b1a060c5ed feat: P0/P3/P5 — دفترا المستأجر ورسم العملية وتوجيه المال
الأساس المالي حسب docs/24: فصل ما نملكه عمّا نحتفظ به لغيرنا.

- جدولان منفصلان (`tenant_revenue_ledger` · `tenant_pending_ledger`) لا جدول
  واحد بعمود نوع: استعلامٌ ينسى الشرط كان يجعل المالك يسحب من مال الركّاب.
  مضافان فقط، والرصيد مشتقّ لا حقل يُحدَّث.
- فهرس فريد (tenant_id, ref) = حارس التسوية المزدوجة. التصادم يُلتقط من
  القاعدة لا بالفحص المسبق وحده — نداءان متزامنان يمرّان معاً قبل أي كتابة.
- رسم العملية من مصدر واحد (35 ل.س · 5 ج.م · 0.20 د.أ) قابل للتجاوز من إعداد
  المستأجر، مع قصّه عند المبلغ حتى لا يخرج المستخدم بصافٍ سالب.
- توجيه الدفع: شحن السائق ← إيراد + رصيده التشغيلي · شحن الراكب ← أمانة ·
  الرسم ← إيراد. وفُتح مسار شحن السائق الذي لم يكن له مدخل إطلاقاً.
- ثغرة سُدّت: `purpose` يصل من الجسم، فراكب كان يستطيع إرسال `credit_topup`
  فيُسجَّل مالُه إيراداً ويُشحن حساب سائق لا يملكه. الدور الآن من التوكن.
- `/admin/overview`: الإيراد من الدفتر، وعمولة الرحلات حقل منفصل عنه.
- `/admin/wallet/summary` و`/revenue-by-reason` لأدمن المستأجر.

25 اختباراً جديداً (188 إجمالاً، كلها خضراء). أحدها أمسك عطلاً فعلياً: صافٍ
صفري كان ينادي الدفتر بصفر فيرمي خطأً بعد قيد الرسم — عملية نصف مطبَّقة.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:04:45 +03:00

279 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tripz — لوحة السوبر-أدمن</title>
<!--
لوحة مالك المنصة (docs/22 — N1). SPA مكتفية ذاتياً بلا خطوة بناء:
Vanilla JS + fetch. تُخدَم كملف ثابت خلف نفس نطاق الـTLS (Nginx location).
المصادقة: سرّ المنصة (x-platform-secret) يُدخَل مرة ويُحفظ في sessionStorage.
كل الحماية على السيرفر (PlatformGuard) — هذه واجهة فقط.
-->
<style>
:root { --bg:#0f1420; --card:#1a2130; --line:#2a3346; --fg:#e6ebf5; --mut:#8b97ad; --acc:#4f8cff; --ok:#2ecc71; --bad:#e74c3c; }
* { box-sizing:border-box; }
body { margin:0; font-family:system-ui,'Segoe UI',Tahoma,sans-serif; background:var(--bg); color:var(--fg); }
header { padding:16px 24px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; }
header h1 { font-size:18px; margin:0; }
.wrap { max-width:1100px; margin:0 auto; padding:24px; }
.card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:20px; margin-bottom:20px; }
.card h2 { margin:0 0 16px; font-size:15px; color:var(--mut); font-weight:600; }
label { display:block; font-size:13px; color:var(--mut); margin:10px 0 4px; }
input, select { width:100%; padding:9px 11px; background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--fg); font-size:14px; }
button { background:var(--acc); color:#fff; border:0; border-radius:8px; padding:10px 18px; font-size:14px; cursor:pointer; }
button.ghost { background:transparent; border:1px solid var(--line); color:var(--fg); }
button:disabled { opacity:.5; cursor:default; }
.row { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
.chips { display:flex; flex-wrap:wrap; gap:8px; margin-top:6px; }
.chip { padding:6px 12px; border:1px solid var(--line); border-radius:20px; font-size:13px; cursor:pointer; user-select:none; }
.chip.on { background:var(--acc); border-color:var(--acc); }
table { width:100%; border-collapse:collapse; }
th, td { text-align:right; padding:10px; border-bottom:1px solid var(--line); font-size:14px; }
th { color:var(--mut); font-weight:600; font-size:12px; }
.badge { padding:2px 9px; border-radius:6px; font-size:12px; }
.badge.active { background:rgba(46,204,113,.15); color:var(--ok); }
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); padding:12px 20px; border-radius:8px; font-size:14px; opacity:0; transition:opacity .2s; }
.toast.ok { background:var(--ok); } .toast.bad { background:var(--bad); } .toast.show { opacity:1; }
.muted { color:var(--mut); font-size:13px; }
dialog { background:var(--card); color:var(--fg); border:1px solid var(--line); border-radius:12px; padding:24px; max-width:460px; width:90%; }
dialog::backdrop { background:rgba(0,0,0,.6); }
</style>
</head>
<body>
<header>
<h1>🚕 Tripz — لوحة السوبر-أدمن</h1>
<span class="muted" id="apiLabel"></span>
<span style="flex:1"></span>
<button class="ghost" id="logoutBtn" style="display:none">تسجيل خروج</button>
</header>
<div class="wrap">
<!-- النظرة الشاملة -->
<div class="card">
<h2>النظرة الشاملة
<select id="ovDays" style="width:auto; display:inline-block; margin-right:10px">
<option value="7">٧ أيام</option><option value="30" selected>٣٠ يوماً</option><option value="90">٩٠ يوماً</option>
</select>
</h2>
<div id="ovTotals" class="muted">…</div>
</div>
<!-- المستأجرون -->
<div class="card">
<h2>المستأجرون</h2>
<table>
<thead><tr><th>الاسم</th><th>slug</th><th>الدولة</th><th>الباقة</th><th>الحالة</th><th>رحلات</th><th>GMV</th><th>عمولة الرحلات</th><th>الإيراد الكلي</th><th></th></tr></thead>
<tbody id="tenantRows"><tr><td colspan="10" class="muted">…</td></tr></tbody>
</table>
</div>
<!-- تزويد مستأجر جديد -->
<div class="card">
<h2>تزويد مستأجر جديد</h2>
<div class="row">
<div><label>الاسم</label><input id="pName" placeholder="Siro" /></div>
<div><label>slug (معرّف)</label><input id="pSlug" placeholder="siro" /></div>
</div>
<div class="row">
<div><label>الدولة</label>
<select id="pCountry"><option value="jo">الأردن</option><option value="sy">سوريا</option><option value="eg">مصر</option></select>
</div>
<div><label>الباقة</label>
<select id="pPlan"><option value="launch">انطلاقة</option><option value="brand">علامة</option><option value="fleet">أسطول+</option><option value="sovereign">سيادة</option></select>
</div>
</div>
<label>اسم التطبيق</label><input id="pAppName" placeholder="سيرو" />
<div class="row">
<div><label>bundle ID (أندرويد)</label><input id="pBidA" placeholder="com.siro.rider" /></div>
<div><label>bundle ID (iOS)</label><input id="pBidI" placeholder="com.siro.rider" /></div>
</div>
<label>وسائل الدفع</label><div class="chips" id="payChips"></div>
<label>الميزات المدفوعة المفعّلة</label><div class="chips" id="featChips"></div>
<div style="margin-top:18px"><button id="provisionBtn">تزويد المستأجر</button></div>
</div>
</div>
<div class="toast" id="toast"></div>
<!-- نافذة السرّ -->
<dialog id="authDlg">
<h2 style="margin-top:0">سرّ المنصة</h2>
<p class="muted">أدخل <code>PLATFORM_SECRET</code> للوصول. يُحفظ في هذه الجلسة فقط.</p>
<input id="secretInput" type="password" placeholder="platform secret" />
<div style="margin-top:16px; text-align:left"><button id="secretOk">دخول</button></div>
</dialog>
<script>
const API = (location.origin.includes('localhost') ? 'http://localhost:4010/api' : '/api');
document.getElementById('apiLabel').textContent = API;
let SECRET = sessionStorage.getItem('tripz_platform_secret') || '';
let CATALOG = { features: [], payment_methods: [] };
const selectedPay = new Set(), selectedFeat = new Set();
const toast = (msg, ok = true) => {
const t = document.getElementById('toast');
t.textContent = msg; t.className = `toast show ${ok ? 'ok' : 'bad'}`;
setTimeout(() => (t.className = 'toast'), 2600);
};
async function api(method, path, body) {
const res = await fetch(API + path, {
method,
headers: { 'Content-Type': 'application/json', 'x-platform-secret': SECRET },
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401) { openAuth(); throw new Error('unauthorized'); }
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.message || `HTTP ${res.status}`);
return json;
}
function openAuth() { document.getElementById('authDlg').showModal(); }
document.getElementById('secretOk').onclick = () => {
SECRET = document.getElementById('secretInput').value.trim();
sessionStorage.setItem('tripz_platform_secret', SECRET);
document.getElementById('authDlg').close();
boot();
};
document.getElementById('logoutBtn').onclick = () => {
sessionStorage.removeItem('tripz_platform_secret'); location.reload();
};
function renderChips(el, items, selected, labelKey = 'name_ar', codeKey = 'code') {
el.innerHTML = '';
for (const it of items) {
const code = typeof it === 'string' ? it : it[codeKey];
const label = typeof it === 'string' ? it : (it[labelKey] || code);
const chip = document.createElement('span');
chip.className = 'chip' + (selected.has(code) ? ' on' : '');
chip.textContent = label;
chip.onclick = () => {
selected.has(code) ? selected.delete(code) : selected.add(code);
chip.classList.toggle('on');
};
el.appendChild(chip);
}
}
// اسم المستأجر يدخله بشر ويُحقن في innerHTML — بلا تهريب يصير اسمٌ فيه
// <script> ثغرة XSS في أخطر لوحة عندنا.
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const money = (n) => Number(n || 0).toLocaleString('en-US', { maximumFractionDigits: 2 });
async function loadTenants() {
const rows = document.getElementById('tenantRows');
const days = document.getElementById('ovDays').value;
try {
const ov = await api('GET', `/admin/overview?days=${days}`);
const t0 = ov.totals || {};
document.getElementById('ovTotals').innerHTML =
`مستأجرون: <b>${t0.tenants ?? 0}</b> (فعّال: ${t0.active_tenants ?? 0}) ·
رحلات: <b>${t0.trips ?? 0}</b> (مكتملة: ${t0.completed_trips ?? 0}) ·
GMV: <b>${money(t0.gmv)}</b> · عمولة رحلات: <b>${money(t0.trip_commission)}</b> · الإيراد الكلي: <b>${money(t0.revenue)}</b>`;
const tenants = ov.tenants || [];
rows.innerHTML = tenants.length ? '' : '<tr><td colspan="10" class="muted">لا مستأجرين بعد</td></tr>';
for (const t of tenants) {
const suspended = t.status !== 'active';
const tr = document.createElement('tr');
tr.innerHTML = `<td>${esc(t.name)}</td><td class="muted">${esc(t.slug)}</td><td>${esc(t.countryPack || '-')}</td>
<td>${esc(t.plan)}</td>
<td><span class="badge ${suspended ? '' : 'active'}">${esc(t.status)}</span></td>
<td>${t.completed_trips}/${t.trips}</td><td>${money(t.gmv)}</td>
<td class="muted">${money(t.trip_commission)}</td><td>${money(t.revenue)}</td>
<td><button class="ghost" data-act="manifest">مانيفست</button>
<button class="ghost" data-act="admin">تعيين أدمن</button>
<button class="ghost" data-act="logo">اللوغو</button>
<button class="ghost" data-act="status">${suspended ? 'تفعيل' : 'تعليق'}</button></td>`;
tr.querySelector('[data-act=manifest]').onclick = async () => {
const m = await api('GET', `/admin/tenants/${t.id}/app-manifest`);
alert(JSON.stringify(m, null, 2));
};
// أول أدمن للمستأجر: تُنشئ الحساب إن لم يوجد (docs/22 — N1d).
// بلا هذا لا يمكن الدخول إلى لوحة مستأجر مزوَّد حديثاً إطلاقاً.
tr.querySelector('[data-act=admin]').onclick = async () => {
const phone = prompt(`رقم هاتف أدمن «${t.name}» (بصيغة دولته: ${t.countryPack})`);
if (!phone) return;
try {
const r = await api('POST', '/platform/users/role', {
tenantSlug: t.slug, phone: phone.trim(), role: 'admin',
});
toast(r.created ? 'أُنشئ الحساب وعُيّن أدمن ✅' : 'صار أدمن ✅');
} catch (e) { toast(e.message, false); }
};
// رفع اللوغو (docs/22 — N2). لا نمرّ بـapi() لأن الرفع multipart
// لا JSON: ضبط Content-Type يدوياً يكسر حدّ الأجزاء (boundary).
tr.querySelector('[data-act=logo]').onclick = () => {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/png,image/jpeg';
inp.onchange = async () => {
const f = inp.files?.[0]; if (!f) return;
const fd = new FormData(); fd.append('file', f);
try {
const res = await fetch(`${API}/admin/tenants/${t.id}/logo`, {
method: 'POST', headers: { 'x-platform-secret': SECRET }, body: fd,
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.message || `HTTP ${res.status}`);
toast(`رُفع اللوغو ${j.logo?.width}×${j.logo?.height} ✅`);
} catch (e) { toast(e.message, false); }
};
inp.click();
};
tr.querySelector('[data-act=status]').onclick = async () => {
// التعليق يقطع الخدمة عن مستأجر حيّ فوراً — لذلك تأكيد صريح.
const next = suspended ? 'active' : 'suspended';
if (!suspended && !confirm(`تعليق «${t.name}»؟ سيتوقّف تطبيقه وسائقوه وركّابه فوراً.`)) return;
try {
await api('PATCH', `/admin/tenants/${t.id}/status`, { status: next });
toast(next === 'active' ? 'أُعيد التفعيل ✅' : 'عُلّق المستأجر ⛔');
loadTenants();
} catch (e) { toast(e.message, false); }
};
rows.appendChild(tr);
}
} catch (e) { if (e.message !== 'unauthorized') rows.innerHTML = `<tr><td colspan="10" class="muted">${esc(e.message)}</td></tr>`; }
}
document.getElementById('ovDays').onchange = loadTenants;
document.getElementById('provisionBtn').onclick = async () => {
const btn = document.getElementById('provisionBtn'); btn.disabled = true;
try {
const feats = {}; for (const f of selectedFeat) feats[f] = true;
await api('POST', '/admin/tenants/provision', {
name: document.getElementById('pName').value.trim(),
slug: document.getElementById('pSlug').value.trim(),
countryPack: document.getElementById('pCountry').value,
plan: document.getElementById('pPlan').value,
features: feats,
paymentMethods: [...selectedPay],
branding: {
app_name: document.getElementById('pAppName').value.trim(),
bundle_id_android: document.getElementById('pBidA').value.trim() || null,
bundle_id_ios: document.getElementById('pBidI').value.trim() || null,
},
});
toast('تمّ تزويد المستأجر ✅');
loadTenants();
} catch (e) { toast(e.message, false); }
finally { btn.disabled = false; }
};
async function boot() {
if (!SECRET) { openAuth(); return; }
document.getElementById('logoutBtn').style.display = '';
try {
CATALOG = await api('GET', '/admin/features');
renderChips(document.getElementById('payChips'), CATALOG.payment_methods || [], selectedPay);
renderChips(document.getElementById('featChips'),
(CATALOG.features || []).map((f) => ({ code: f, name_ar: f })), selectedFeat);
loadTenants();
} catch (e) { /* openAuth already fired on 401 */ }
}
boot();
</script>
</body>
</html>