feat: N4/N5 — لوحتا أدمن المستأجر وخدمة العملاء (ويب)
نقاط سرد إدارية (خلف RolesGuard admin/dispatcher): - GET /drivers/admin/list — سائقو المستأجر (بترشيح الاعتماد) - GET /trips/admin/list — رحلات المستأجر (بترشيح الحالة/الراكب) - GET /payouts/admin/list — سحوبات المستأجر (بترشيح الحالة) - GET /admin/users/search?phone= — بحث خدمة العملاء (يُطبَّع ويُبحث بالفهرس الأعمى، لا يُعرض الرقم الخام) اللوحتان (SPA مكتفية ذاتياً، vanilla JS، RTL): - admin-web: دخول أدمن (هاتف+OTP) → سائقون (اعتماد) · رحلات · مراجعة وثائق (قبول/رفض) · سحوبات (تحويل/فشل). تحقّقت أنها تُصيَّر نظيفاً في المعاينة. - service-web: بحث مستخدم برقمه → بياناته ورحلاته + تفاصيل رحلة بالمعرّف. المصادقة هاتف+OTP → JWT بدور admin/dispatcher (نفس الموبايل). الحماية على السيرفر؛ الواجهة عرض فقط. الوصول بـ?tenant=<slug>. E2E موسّع: يثبت أن السائق/الراكب (غير أدمن) يُرفضان من النقاط الإدارية (403). الشكاوى تحتاج وحدة complaints (لاحقاً). لا هجرة. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c93be38622
commit
5cad6adbde
@@ -253,6 +253,16 @@ async function main() {
|
||||
const dupRate = await api('POST', `/trips/${tripId}/rate`, rider.token, { stars: 3 }, { allowError: true });
|
||||
check('منع التقييم المزدوج', !dupRate.ok && dupRate.status === 400);
|
||||
|
||||
// ═══ 11.5 حراسة النقاط الإدارية (docs/22 — N4) ═══
|
||||
console.log('\n11.5) حراسة لوحة الأدمن (RolesGuard)');
|
||||
const adminAsDriver = await api('GET', '/drivers/admin/list', driver.token, null, { allowError: true });
|
||||
check('السائق (غير أدمن) يُرفض من /drivers/admin/list', adminAsDriver.status === 403,
|
||||
`status=${adminAsDriver.status}`);
|
||||
const tripsAsRider = await api('GET', '/trips/admin/list', rider.token, null, { allowError: true });
|
||||
check('الراكب يُرفض من /trips/admin/list', tripsAsRider.status === 403);
|
||||
const searchAsRider = await api('GET', '/admin/users/search?phone=07900000000', rider.token, null, { allowError: true });
|
||||
check('الراكب يُرفض من بحث خدمة العملاء', searchAsRider.status === 403);
|
||||
|
||||
// ═══ 12. السحب بـOTP (تدفّق خطوتين) ═══
|
||||
console.log('\n12) سحب أرباح السائق (OTP خطوتين)');
|
||||
// السائق يشحن محفظة أرباحه أولاً ليكون له رصيد للسحب
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { DriversService } from './drivers.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('drivers')
|
||||
@@ -11,6 +13,14 @@ import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator
|
||||
export class DriversController {
|
||||
constructor(private readonly drivers: DriversService) {}
|
||||
|
||||
/** سرد سائقي المستأجر — للوحة الأدمن (docs/22 — N4). */
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('admin', 'dispatcher')
|
||||
@Get('admin/list')
|
||||
adminList(@CurrentUser() user: AuthUser, @Query('verification') verification?: string) {
|
||||
return this.drivers.listByTenant(user.tenantId, verification);
|
||||
}
|
||||
|
||||
@Post('apply')
|
||||
apply(@CurrentUser() user: AuthUser, @Body() body: any) {
|
||||
return this.drivers.apply(user.tenantId, user.userId, body);
|
||||
|
||||
@@ -102,6 +102,13 @@ export class DriversService {
|
||||
});
|
||||
}
|
||||
|
||||
/** سرد سائقي المستأجر للوحة الأدمن (docs/22 — N4). */
|
||||
listByTenant(tenantId: string, verification?: string): Promise<Driver[]> {
|
||||
const where: any = { tenant_id: tenantId };
|
||||
if (verification) where.verification_status = verification;
|
||||
return this.repo.find({ where, order: { created_at: 'DESC' }, take: 200 });
|
||||
}
|
||||
|
||||
async setRating(tenantId: string, driverId: string, rating: number): Promise<void> {
|
||||
await this.repo.update(
|
||||
{ tenant_id: tenantId, id: driverId },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { PayoutsService, RequestContext } from './payouts.service';
|
||||
@@ -69,6 +69,14 @@ export class PayoutsController {
|
||||
return this.payouts.listMine(user.tenantId, user.userId);
|
||||
}
|
||||
|
||||
/** سرد سحوبات المستأجر — للأدمن، بترشيح الحالة (docs/22 — N4). */
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'dispatcher')
|
||||
@Get('admin/list')
|
||||
adminList(@CurrentUser() user: AuthUser, @Query('status') status?: string) {
|
||||
return this.payouts.listByTenant(user.tenantId, status);
|
||||
}
|
||||
|
||||
// الأدمن يؤكّد/يفشل التحويل
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'dispatcher')
|
||||
|
||||
@@ -174,6 +174,13 @@ export class PayoutsService {
|
||||
});
|
||||
}
|
||||
|
||||
/** سرد سحوبات المستأجر للأدمن (docs/22 — N4)، بترشيح الحالة. */
|
||||
listByTenant(tenantId: string, status?: string) {
|
||||
const where: any = { tenant_id: tenantId };
|
||||
if (status) where.status = status;
|
||||
return this.repo.find({ where, order: { created_at: 'DESC' }, take: 100 });
|
||||
}
|
||||
|
||||
/** الأدمن يؤكّد أن المبلغ حُوِّل خارجياً. */
|
||||
async complete(
|
||||
tenantId: string,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { TripsService, RequestTripDto } from './trips.service';
|
||||
import { TripStatus } from './entities/trip.entity';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('trips')
|
||||
@@ -12,6 +14,18 @@ import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator
|
||||
export class TripsController {
|
||||
constructor(private readonly trips: TripsService) {}
|
||||
|
||||
/** سرد رحلات المستأجر — للوحة الأدمن، بترشيح الحالة (docs/22 — N4). */
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('admin', 'dispatcher')
|
||||
@Get('admin/list')
|
||||
adminList(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('status') status?: string,
|
||||
@Query('rider') rider?: string,
|
||||
) {
|
||||
return this.trips.listByTenant(user.tenantId, status, rider);
|
||||
}
|
||||
|
||||
// الراكب يطلب رحلة
|
||||
@Post()
|
||||
request(@CurrentUser() user: AuthUser, @Body() body: RequestTripDto) {
|
||||
|
||||
@@ -115,6 +115,14 @@ export class TripsService {
|
||||
return qb.orderBy('t.completed_at', 'DESC').take(limit).getMany();
|
||||
}
|
||||
|
||||
/** سرد رحلات المستأجر للوحة الأدمن، بترشيح الحالة أو الراكب (docs/22 — N4/N5). */
|
||||
listByTenant(tenantId: string, status?: string, riderId?: string): Promise<Trip[]> {
|
||||
const where: any = { tenant_id: tenantId };
|
||||
if (status) where.status = status;
|
||||
if (riderId) where.rider_id = riderId; // خدمة العملاء: رحلات مستخدم بعينه
|
||||
return this.trips.find({ where, order: { requested_at: 'DESC' }, take: 100 });
|
||||
}
|
||||
|
||||
listForDriver(tenantId: string, driverId: string): Promise<Trip[]> {
|
||||
return this.trips.find({
|
||||
where: { tenant_id: tenantId, driver_id: driverId },
|
||||
|
||||
@@ -2,10 +2,12 @@ import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
@@ -29,6 +31,20 @@ export class AdminUsersController {
|
||||
private readonly phones: PhoneService,
|
||||
) {}
|
||||
|
||||
/** بحث خدمة العملاء عن مستخدم برقم هاتفه (docs/22 — N5). يُطبَّع ثم يُبحث
|
||||
* بالفهرس الأعمى — لا يُعرض الرقم الخام. نطاقه المستأجر من التوكن. */
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'dispatcher')
|
||||
@Get('admin/users/search')
|
||||
async search(@CurrentUser() user: AuthUser, @Query('phone') phone: string) {
|
||||
if (!phone) throw new BadRequestException('phone is required');
|
||||
const tenant = await this.tenants.resolve(user.tenantId);
|
||||
const canonical = this.phones.normalize(phone, tenant?.countryPack ?? 'jo');
|
||||
const u = await this.users.findByPhone(user.tenantId, canonical);
|
||||
return u ? { id: u.id, phone: u.phone, role: u.role, rating: u.rating, status: u.status } : null;
|
||||
}
|
||||
|
||||
// أدمن **المستأجر** يعيّن دور مستخدم داخل مستأجره هو — هذه ليست نقطة منصة،
|
||||
// ونطاقها مضمون بـ user.tenantId القادم من التوكن.
|
||||
@ApiBearerAuth()
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<!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 — N4). SPA مكتفية ذاتياً (vanilla JS + fetch).
|
||||
المصادقة: هاتف + OTP (نفس تدفّق الموبايل) → JWT بدور admin/dispatcher.
|
||||
كل الحماية على السيرفر (RolesGuard). هذه واجهة فقط.
|
||||
-->
|
||||
<style>
|
||||
:root { --bg:#0f1420; --card:#1a2130; --line:#2a3346; --fg:#e6ebf5; --mut:#8b97ad; --acc:#4f8cff; --ok:#2ecc71; --warn:#f39c12; --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:14px 22px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:14px; }
|
||||
header h1 { font-size:17px; margin:0; }
|
||||
nav { display:flex; gap:6px; padding:0 22px; border-bottom:1px solid var(--line); }
|
||||
nav button { background:none; border:0; color:var(--mut); padding:12px 14px; cursor:pointer; font-size:14px; border-bottom:2px solid transparent; }
|
||||
nav button.on { color:var(--fg); border-bottom-color:var(--acc); }
|
||||
.wrap { max-width:1200px; margin:0 auto; padding:22px; }
|
||||
.card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:18px; margin-bottom:18px; }
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
th,td { text-align:right; padding:9px 10px; border-bottom:1px solid var(--line); font-size:13px; }
|
||||
th { color:var(--mut); font-weight:600; font-size:12px; }
|
||||
button.act { background:var(--acc); color:#fff; border:0; border-radius:6px; padding:6px 12px; font-size:13px; cursor:pointer; }
|
||||
button.act.ok { background:var(--ok); } button.act.bad { background:var(--bad); }
|
||||
input,select { padding:9px 11px; background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--fg); font-size:14px; }
|
||||
.badge { padding:2px 8px; border-radius:6px; font-size:11px; }
|
||||
.b-pending { background:rgba(243,156,18,.15); color:var(--warn); }
|
||||
.b-approved,.b-paid,.b-completed { background:rgba(46,204,113,.15); color:var(--ok); }
|
||||
.b-rejected,.b-failed,.b-cancelled { background:rgba(231,76,60,.15); color:var(--bad); }
|
||||
.muted { color:var(--mut); font-size:13px; }
|
||||
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); padding:12px 20px; border-radius:8px; opacity:0; transition:opacity .2s; }
|
||||
.toast.ok{background:var(--ok)} .toast.bad{background:var(--bad)} .toast.show{opacity:1}
|
||||
dialog { background:var(--card); color:var(--fg); border:1px solid var(--line); border-radius:12px; padding:24px; max-width:420px; width:90%; }
|
||||
dialog::backdrop { background:rgba(0,0,0,.6); }
|
||||
dialog input { width:100%; margin-top:8px; }
|
||||
.flex { display:flex; gap:10px; align-items:center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🚕 Tripz — لوحة الأدمن</h1>
|
||||
<span class="muted" id="who"></span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="act bad" id="logout" style="display:none">خروج</button>
|
||||
</header>
|
||||
<nav id="nav" style="display:none">
|
||||
<button data-tab="drivers" class="on">السائقون</button>
|
||||
<button data-tab="trips">الرحلات</button>
|
||||
<button data-tab="docs">الوثائق</button>
|
||||
<button data-tab="payouts">السحوبات</button>
|
||||
</nav>
|
||||
<div class="wrap" id="main" style="display:none">
|
||||
<div class="card"><div id="tabBody"><span class="muted">…</span></div></div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<dialog id="loginDlg">
|
||||
<h2 style="margin-top:0">دخول الأدمن</h2>
|
||||
<p class="muted">هاتف + رمز تحقّق (OTP).</p>
|
||||
<input id="phone" placeholder="07xxxxxxxx" />
|
||||
<div id="otpStep" style="display:none">
|
||||
<input id="otp" placeholder="الرمز (1234 في وضع التطوير)" />
|
||||
</div>
|
||||
<div class="flex" style="margin-top:16px; justify-content:flex-start">
|
||||
<button class="act" id="sendOtp">إرسال الرمز</button>
|
||||
<button class="act ok" id="verify" style="display:none">دخول</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
const API = location.origin.includes('localhost') ? 'http://localhost:4010/api' : '/api';
|
||||
const TENANT = new URLSearchParams(location.search).get('tenant') || 'siro';
|
||||
let TOKEN = sessionStorage.getItem('tripz_admin_token') || '';
|
||||
let tab = 'drivers';
|
||||
|
||||
const toast = (m, ok = true) => { const t = document.getElementById('toast'); t.textContent = m; 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-tenant-id':TENANT, ...(TOKEN?{Authorization:`Bearer ${TOKEN}`}:{}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) { if(path.startsWith('/drivers/admin')) openLogin(); }
|
||||
const json = await res.json().catch(()=>({}));
|
||||
if (!res.ok) throw new Error(json.message || `HTTP ${res.status}`);
|
||||
return json;
|
||||
}
|
||||
|
||||
function openLogin(){ document.getElementById('loginDlg').showModal(); }
|
||||
document.getElementById('sendOtp').onclick = async () => {
|
||||
const phone = document.getElementById('phone').value.trim();
|
||||
try { const r = await api('POST','/auth/send-otp',{phone}); toast(r.dev_code?`رمز التطوير: ${r.dev_code}`:'أُرسل الرمز');
|
||||
document.getElementById('otpStep').style.display=''; document.getElementById('verify').style.display=''; }
|
||||
catch(e){ toast(e.message,false); }
|
||||
};
|
||||
document.getElementById('verify').onclick = async () => {
|
||||
try {
|
||||
const r = await api('POST','/auth/verify-otp',{ phone:document.getElementById('phone').value.trim(), code:document.getElementById('otp').value.trim() });
|
||||
if (!['admin','dispatcher'].includes(r.user?.role)) { toast('هذا الحساب ليس أدمن',false); return; }
|
||||
TOKEN = r.access_token; sessionStorage.setItem('tripz_admin_token',TOKEN);
|
||||
document.getElementById('loginDlg').close(); boot();
|
||||
} catch(e){ toast(e.message,false); }
|
||||
};
|
||||
document.getElementById('logout').onclick = () => { sessionStorage.removeItem('tripz_admin_token'); location.reload(); };
|
||||
|
||||
document.querySelectorAll('#nav button').forEach(b => b.onclick = () => {
|
||||
document.querySelectorAll('#nav button').forEach(x=>x.classList.remove('on'));
|
||||
b.classList.add('on'); tab = b.dataset.tab; render();
|
||||
});
|
||||
|
||||
const badge = (s) => `<span class="badge b-${s}">${s}</span>`;
|
||||
|
||||
async function render() {
|
||||
const body = document.getElementById('tabBody'); body.innerHTML = '<span class="muted">…</span>';
|
||||
try {
|
||||
if (tab === 'drivers') {
|
||||
const ds = await api('GET','/drivers/admin/list');
|
||||
body.innerHTML = table(['السائق','الفئة','الحالة','متصل',''], ds.map(d=>[
|
||||
d.id.slice(0,8), d.service_class, badge(d.verification_status), d.is_online?'🟢':'⚪',
|
||||
d.verification_status==='pending'?`<button class="act ok" onclick="approve('${d.id}')">اعتماد</button>`:'' ]));
|
||||
} else if (tab === 'trips') {
|
||||
const ts = await api('GET','/trips/admin/list');
|
||||
body.innerHTML = table(['الرحلة','الحالة','الأجرة','المسافة'], ts.map(t=>[
|
||||
t.id.slice(0,8), badge(t.status), t.final_fare??t.quoted_fare??'-', (t.distance_km??'-')+' كم' ]));
|
||||
} else if (tab === 'docs') {
|
||||
const dd = await api('GET','/admin/documents/pending');
|
||||
body.innerHTML = table(['السائق','النوع','الوجه',''], (dd||[]).map(x=>[
|
||||
x.driver_id.slice(0,8), x.type, x.side,
|
||||
`<button class="act ok" onclick="reviewDoc('${x.id}','approved')">قبول</button>
|
||||
<button class="act bad" onclick="reviewDoc('${x.id}','rejected')">رفض</button>` ]));
|
||||
} else if (tab === 'payouts') {
|
||||
const ps = await api('GET','/payouts/admin/list?status=requested');
|
||||
body.innerHTML = table(['السائق','المبلغ','القناة','الحالة',''], ps.map(p=>[
|
||||
p.driver_user_id.slice(0,8), `${p.amount} ${p.currency}`, p.channel, badge(p.status),
|
||||
`<button class="act ok" onclick="payout('${p.id}','complete')">تحويل</button>
|
||||
<button class="act bad" onclick="payout('${p.id}','fail')">فشل</button>` ]));
|
||||
}
|
||||
} catch(e){ body.innerHTML = `<span class="muted">${e.message}</span>`; }
|
||||
}
|
||||
function table(cols, rows){
|
||||
return `<table><thead><tr>${cols.map(c=>`<th>${c}</th>`).join('')}</tr></thead><tbody>${
|
||||
rows.length?rows.map(r=>`<tr>${r.map(c=>`<td>${c}</td>`).join('')}</tr>`).join(''):`<tr><td colspan="${cols.length}" class="muted">لا بيانات</td></tr>`}</tbody></table>`;
|
||||
}
|
||||
window.approve = async(id)=>{ try{await api('PATCH',`/drivers/${id}/approve`,{});toast('اعتُمد');render();}catch(e){toast(e.message,false);} };
|
||||
window.reviewDoc = async(id,s)=>{ try{await api('PATCH',`/admin/documents/${id}/review`,{status:s});toast('تمّت المراجعة');render();}catch(e){toast(e.message,false);} };
|
||||
window.payout = async(id,act)=>{ try{await api('PATCH',`/payouts/${id}/${act}`,{});toast('تمّ');render();}catch(e){toast(e.message,false);} };
|
||||
|
||||
function boot(){
|
||||
if (!TOKEN){ openLogin(); return; }
|
||||
document.getElementById('nav').style.display='flex';
|
||||
document.getElementById('main').style.display='block';
|
||||
document.getElementById('logout').style.display='';
|
||||
document.getElementById('who').textContent = `المستأجر: ${TENANT}`;
|
||||
render();
|
||||
}
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,128 @@
|
||||
<!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 — N5). SPA مكتفية ذاتياً. المصادقة هاتف+OTP →
|
||||
JWT بدور dispatcher/admin. بحث مستخدم برقمه (يُطبَّع ويُبحث بالفهرس الأعمى
|
||||
على السيرفر) + عرض رحلاته + تفاصيل رحلة بالمعرّف. الحماية على السيرفر.
|
||||
-->
|
||||
<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:14px 22px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:14px; }
|
||||
header h1 { font-size:17px; margin:0; }
|
||||
.wrap { max-width:900px; margin:0 auto; padding:22px; }
|
||||
.card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:18px; margin-bottom:18px; }
|
||||
.card h2 { margin:0 0 14px; font-size:14px; color:var(--mut); font-weight:600; }
|
||||
input { padding:9px 11px; background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--fg); font-size:14px; }
|
||||
button.act { background:var(--acc); color:#fff; border:0; border-radius:6px; padding:9px 16px; font-size:14px; cursor:pointer; }
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
th,td { text-align:right; padding:9px 10px; border-bottom:1px solid var(--line); font-size:13px; }
|
||||
th { color:var(--mut); font-weight:600; font-size:12px; }
|
||||
.badge { padding:2px 8px; border-radius:6px; font-size:11px; }
|
||||
.b-paid,.b-completed{background:rgba(46,204,113,.15);color:var(--ok)} .b-cancelled{background:rgba(231,76,60,.15);color:var(--bad)}
|
||||
.muted { color:var(--mut); font-size:13px; }
|
||||
.kv { display:grid; grid-template-columns:auto 1fr; gap:6px 14px; font-size:14px; }
|
||||
.kv b { color:var(--mut); font-weight:600; }
|
||||
.flex { display:flex; gap:10px; }
|
||||
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); padding:12px 20px; border-radius:8px; opacity:0; transition:opacity .2s; }
|
||||
.toast.ok{background:var(--ok)} .toast.bad{background:var(--bad)} .toast.show{opacity:1}
|
||||
dialog { background:var(--card); color:var(--fg); border:1px solid var(--line); border-radius:12px; padding:24px; max-width:420px; width:90%; }
|
||||
dialog::backdrop{background:rgba(0,0,0,.6)} dialog input{width:100%;margin-top:8px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🎧 Tripz — خدمة العملاء</h1>
|
||||
<span class="muted" id="who"></span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="act" id="logout" style="display:none" style="background:var(--bad)">خروج</button>
|
||||
</header>
|
||||
<div class="wrap" id="main" style="display:none">
|
||||
<div class="card">
|
||||
<h2>بحث عن مستخدم برقم الهاتف</h2>
|
||||
<div class="flex"><input id="q" placeholder="07xxxxxxxx" style="flex:1" /><button class="act" id="searchBtn">بحث</button></div>
|
||||
<div id="userResult" style="margin-top:14px"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>تفاصيل رحلة بالمعرّف</h2>
|
||||
<div class="flex"><input id="tid" placeholder="trip id" style="flex:1" /><button class="act" id="tripBtn">عرض</button></div>
|
||||
<div id="tripResult" style="margin-top:14px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<dialog id="loginDlg">
|
||||
<h2 style="margin-top:0">دخول خدمة العملاء</h2>
|
||||
<p class="muted">هاتف + رمز تحقّق.</p>
|
||||
<input id="phone" placeholder="07xxxxxxxx" />
|
||||
<div id="otpStep" style="display:none"><input id="otp" placeholder="الرمز (1234 تطوير)" /></div>
|
||||
<div class="flex" style="margin-top:16px"><button class="act" id="sendOtp">إرسال الرمز</button><button class="act" id="verify" style="display:none;background:var(--ok)">دخول</button></div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
const API = location.origin.includes('localhost') ? 'http://localhost:4010/api' : '/api';
|
||||
const TENANT = new URLSearchParams(location.search).get('tenant') || 'siro';
|
||||
let TOKEN = sessionStorage.getItem('tripz_cs_token') || '';
|
||||
const toast=(m,ok=true)=>{const t=document.getElementById('toast');t.textContent=m;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-tenant-id':TENANT,...(TOKEN?{Authorization:`Bearer ${TOKEN}`}:{})},body:body?JSON.stringify(body):undefined});
|
||||
const json = await res.json().catch(()=>({}));
|
||||
if(!res.ok) throw new Error(json.message||`HTTP ${res.status}`);
|
||||
return json;
|
||||
}
|
||||
const badge=(s)=>`<span class="badge b-${s}">${s}</span>`;
|
||||
|
||||
document.getElementById('searchBtn').onclick = async () => {
|
||||
const box = document.getElementById('userResult'); box.innerHTML='<span class="muted">…</span>';
|
||||
try {
|
||||
const u = await api('GET',`/admin/users/search?phone=${encodeURIComponent(document.getElementById('q').value.trim())}`);
|
||||
if(!u){ box.innerHTML='<span class="muted">لا مستخدم بهذا الرقم</span>'; return; }
|
||||
const trips = await api('GET',`/trips/admin/list?rider=${u.id}`);
|
||||
box.innerHTML = `<div class="kv"><b>المعرّف</b><span>${u.id}</span><b>الهاتف</b><span>${u.phone}</span>
|
||||
<b>الدور</b><span>${u.role}</span><b>التقييم</b><span>${u.rating??'-'}</span><b>الحالة</b><span>${u.status}</span></div>
|
||||
<h2 style="margin-top:16px">رحلاته (${trips.length})</h2>
|
||||
<table><thead><tr><th>الرحلة</th><th>الحالة</th><th>الأجرة</th></tr></thead><tbody>${
|
||||
trips.length?trips.map(t=>`<tr><td>${t.id.slice(0,8)}</td><td>${badge(t.status)}</td><td>${t.final_fare??t.quoted_fare??'-'}</td></tr>`).join(''):'<tr><td colspan=3 class="muted">لا رحلات</td></tr>'}</tbody></table>`;
|
||||
} catch(e){ box.innerHTML=`<span class="muted">${e.message}</span>`; }
|
||||
};
|
||||
|
||||
document.getElementById('tripBtn').onclick = async () => {
|
||||
const box = document.getElementById('tripResult'); box.innerHTML='<span class="muted">…</span>';
|
||||
try {
|
||||
const t = await api('GET',`/trips/${document.getElementById('tid').value.trim()}`);
|
||||
if(!t||!t.id){ box.innerHTML='<span class="muted">لا رحلة بهذا المعرّف</span>'; return; }
|
||||
box.innerHTML = `<div class="kv"><b>الحالة</b><span>${badge(t.status)}</span><b>الأجرة</b><span>${t.final_fare??t.quoted_fare??'-'} ${t.currency||''}</span>
|
||||
<b>المسافة</b><span>${t.distance_km??'-'} كم</span><b>وسيلة الدفع</b><span>${t.payment_method}</span>
|
||||
<b>أُلغيت من</b><span>${t.cancelled_by||'-'}</span><b>رسم الإلغاء</b><span>${t.cancel_fee??'-'}</span></div>`;
|
||||
} catch(e){ box.innerHTML=`<span class="muted">${e.message}</span>`; }
|
||||
};
|
||||
|
||||
function openLogin(){ document.getElementById('loginDlg').showModal(); }
|
||||
document.getElementById('sendOtp').onclick = async () => {
|
||||
try{ const r=await api('POST','/auth/send-otp',{phone:document.getElementById('phone').value.trim()}); toast(r.dev_code?`رمز: ${r.dev_code}`:'أُرسل الرمز');
|
||||
document.getElementById('otpStep').style.display=''; document.getElementById('verify').style.display=''; }catch(e){toast(e.message,false);}
|
||||
};
|
||||
document.getElementById('verify').onclick = async () => {
|
||||
try{ const r=await api('POST','/auth/verify-otp',{phone:document.getElementById('phone').value.trim(),code:document.getElementById('otp').value.trim()});
|
||||
if(!['admin','dispatcher'].includes(r.user?.role)){toast('ليس حساب خدمة عملاء',false);return;}
|
||||
TOKEN=r.access_token; sessionStorage.setItem('tripz_cs_token',TOKEN); document.getElementById('loginDlg').close(); boot();
|
||||
}catch(e){toast(e.message,false);}
|
||||
};
|
||||
document.getElementById('logout').onclick = ()=>{ sessionStorage.removeItem('tripz_cs_token'); location.reload(); };
|
||||
|
||||
function boot(){
|
||||
if(!TOKEN){ openLogin(); return; }
|
||||
document.getElementById('main').style.display='block';
|
||||
document.getElementById('logout').style.display='';
|
||||
document.getElementById('who').textContent=`المستأجر: ${TENANT}`;
|
||||
}
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -81,8 +81,11 @@
|
||||
| N1 | **لوحة سوبر-أدمن (ويب)**: إنشاء مستأجر · اسم/لوغو/دولة/دفع/باقة/ميزات · مراقبة. | 🔵 الباك إند ✅ (`provision` · `summary` · `app-manifest` · `payment-methods` · `branding` خلف PlatformGuard؛ كتالوج `payment-methods.ts`) + واجهة ويب مكتفية ذاتياً (`dashboards/superadmin-web/index.html`). الباقي: لوحة أغنى (تعديل/تعطيل/GMV). |
|
||||
| N2 | **رفع اللوغو + توليد الأيقونات/splash**. | 🔵 الباك إند ✅ (`POST /admin/tenants/:id/logo` + `GET /tenant/logo/:slug` **عام لأصل الهوية فقط** — لا يخدم مجلد التخزين كاملاً حتى لا تُسرَّب صور الوثائق). توليد الأيقونات نفسه في N3. |
|
||||
| N3 | **سكربت توليد التطبيق الواحد**: bundle ID · أيقونات/splash من اللوغو · FCM · **إضافات Kotlin/C++ الأصيلة** (overlay · method channels · NDK) · بناء · **Shorebird**. | 🔵 `scripts/generate-tenant-app.sh` — يجلب المانيفست ويضبط الهيكل؛ خطوات فلاتر موسومة [Q] تُوصَل عند بناء المجموعة Q (المشروع غير موجود بعد). |
|
||||
| N4 | **لوحة أدمن المستأجر (ويب)**: dispatch · سائقون · رحلات · تعرفة · تقارير · مراجعة وثائق. | ⏳ التالي |
|
||||
| N5 | **لوحة خدمة العملاء (ويب)**: بحث مستخدم · شكاوى · تدخّل. | ⏳ |
|
||||
| N4 | **لوحة أدمن المستأجر (ويب)**: سائقون (اعتماد) · رحلات · مراجعة وثائق · سحوبات (تحويل/فشل). | ✅ `dashboards/admin-web/index.html` + نقاط سرد إدارية (`/drivers/admin/list` · `/trips/admin/list` · `/payouts/admin/list` خلف RolesGuard admin/dispatcher). التعرفة والتقارير تُضاف مع L. |
|
||||
| N5 | **لوحة خدمة العملاء (ويب)**: بحث مستخدم برقمه (فهرس أعمى) · رحلاته · تفاصيل رحلة بالمعرّف. | ✅ `dashboards/service-web/index.html` + `GET /admin/users/search` · `/trips/admin/list?rider=`. الشكاوى تحتاج وحدة complaints (لا توجد بعد — بند لاحق). |
|
||||
|
||||
**مصادقة اللوحتين**: هاتف + OTP → JWT بدور admin/dispatcher (نفس تدفّق الموبايل). كل الحماية على السيرفر (RolesGuard) — الواجهة عرض فقط. الوصول للوحة معيَّن بـ`?tenant=<slug>`.
|
||||
**اختبار الحراسة**: E2E يثبت أن السائق/الراكب (غير أدمن) يُرفضان من كل النقاط الإدارية (403).
|
||||
|
||||
**اختبار**: `backend/scripts/provision-test.mjs` (يتطلّب `PLATFORM_SECRET`) — يثبت الحارس، التزويد، تصفية الدفع، الاستحقاقات، والمانيفست.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user