Commit af3c054a authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(orgs): the manager is where organisations are run, end to end

The mobile app has no org management (owner decision 2026-10-26), so the
organizations module now covers everything an org owner, admin or staff needs:

- Settings: name, description, tag (2-5, unique, case-blind), sticker hue
  (the game's families only), join policy, games, member invites; logo and
  banner to the public org-logos / org-banners buckets (type, size and
  minimum dimensions checked before anything is saved; a replaced or removed
  image is deleted from storage).
- Platform decisions (staff only): capacity tier (free 500, event 5,000,
  partner unlimited) or an explicit cap never below the member count,
  verification badge, suspend / lift suspension through org_set_suspended
  (revokes every live code in the same call, reason required), soft delete.
- Members: add by username, role change, mute for a period or until lifted,
  kick (status left, never deleted), ban with a reason (closes the player's
  pending applications through decide_application), unban, reactivate; the
  last active owner cannot be kicked, banned or demoted; the 5-org limit,
  capacity and bans are refused as the database functions refuse them. Each
  action notifies the player (org_* bell types) and lands in
  org_activity_log, shown on the org page in Arabic.
- Reports against orgs (org_reports): a platform queue and a per-org tab,
  with status and resolution.

OrgScope now also checks the action, not just the module, for org-scoped
accounts: org_manager can look, draft and create codes but cannot change
members or settings, decide applications, revoke or reverse codes, or
publish. The views hide what the role cannot do.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 59a746e4
......@@ -140,6 +140,8 @@ return [
// Organizations
'organizations' => ['module' => 'organizations', 'action' => 'list'],
'organizations/create' => ['module' => 'organizations', 'action' => 'create'],
'organizations/reports' => ['module' => 'organizations', 'action' => 'reports'],
'organizations/reports/{reportId}/update' => ['module' => 'organizations', 'action' => 'reportUpdate'],
'organizations/store' => ['module' => 'organizations', 'action' => 'store'],
'organizations/{id}' => ['module' => 'organizations', 'action' => 'show'],
'organizations/{id}/edit' => ['module' => 'organizations', 'action' => 'edit'],
......@@ -149,6 +151,8 @@ return [
'organizations/{id}/members' => ['module' => 'organizations', 'action' => 'members'],
'organizations/{id}/members/add' => ['module' => 'organizations', 'action' => 'addMember'],
'organizations/{id}/members/{memberId}/remove' => ['module' => 'organizations', 'action' => 'removeMember'],
'organizations/{id}/members/{memberId}/action' => ['module' => 'organizations', 'action' => 'memberAction'],
'organizations/{id}/suspend' => ['module' => 'organizations', 'action' => 'suspend'],
// Economy
'economy' => ['module' => 'economy', 'action' => 'index'],
......
......@@ -17,7 +17,8 @@
* - pins list pages to the account's org ($_GET['org_id']), and sends the
* org directory straight to the account's own org;
* - refuses, for these accounts, every module their role has no permission
* for at all in config/permissions.php (settings, users, branding, ...).
* for at all in config/permissions.php (settings, users, branding, ...),
* and the org-changing actions their role lacks (ACTION_PERMS).
*
* Platform staff (superadmin, admin, and any account without org_id) are not
* affected.
......@@ -53,6 +54,35 @@ class OrgScope
'org-partnerships' => ['org_partnerships', ['org_a_id', 'org_b_id']],
];
/**
* For the modules that change an org, the permission an org-scoped role
* needs per action (config/permissions.php). org_admin holds them all;
* org_manager can look, draft and create codes, but cannot change members,
* settings, decide applications, revoke or reverse codes, or publish.
* Actions not listed need only some permission on the module.
*/
private const ACTION_PERMS = [
'organizations' => [
'edit' => 'organizations.update', 'update' => 'organizations.update',
'addMember' => 'organizations.members', 'memberAction' => 'organizations.members', 'removeMember' => 'organizations.members',
'verify' => 'organizations.verify', 'suspend' => 'organizations.delete', 'delete' => 'organizations.delete',
'reports' => 'organizations.verify', 'reportUpdate' => 'organizations.verify',
],
'org-applications' => [
'approve' => 'org-applications.approve', 'reject' => 'org-applications.approve',
'bulkApprove' => 'org-applications.approve', 'bulkReject' => 'org-applications.approve',
],
'org-invites' => [
'create' => 'org-invites.create', 'store' => 'org-invites.create', 'bulk' => 'org-invites.create', 'bulkStore' => 'org-invites.create',
'revoke' => 'org-invites.delete', 'reverse' => 'org-invites.delete',
],
'org-announcements' => [
'create' => 'org-announcements.create', 'store' => 'org-announcements.create',
'edit' => 'org-announcements.update', 'update' => 'org-announcements.update',
'publish' => 'org-announcements.update', 'unpublish' => 'org-announcements.update', 'delete' => 'org-announcements.delete',
],
];
/** Request fields that name an org. */
private const ORG_FIELDS = ['org_id', 'org', 'to_org_id', 'from_org_id', 'challenged_org_id', 'challenger_org_id', 'org_a_id', 'org_b_id', 'partner_org_id'];
......@@ -61,12 +91,13 @@ class OrgScope
return $module === 'organizations' || str_starts_with($module, 'org-');
}
public static function enforce(string $module, string $pattern, array $params): void
public static function enforce(string $module, string $pattern, array $params, string $action = ''): void
{
if (!Auth::isOrgScoped() || in_array(Auth::role(), ['superadmin', 'admin'], true)) return;
$own = Auth::orgId();
if (!self::roleTouchesModule($module)) self::deny();
if (($need = self::ACTION_PERMS[$module][$action] ?? null) && !Auth::hasPermission($need)) self::deny();
if (!self::isOrgModule($module)) return;
// The org directory: an org account has exactly one org.
......
......@@ -51,7 +51,7 @@ class Router
Auth::requireAuth();
// An org-scoped staff account stays inside its own org, whatever the URL says.
OrgScope::enforce($module, $pattern, $params);
OrgScope::enforce($module, $pattern, $params, $action);
$controllerPath = MODULES_PATH . "/{$module}/controller.php";
if (!file_exists($controllerPath)) {
......
<?php $canDecide = !Auth::isOrgScoped() || Auth::hasPermission('org-applications.approve'); // org_manager can look, not decide ?>
<div class="content-header">
<div class="flex items-center gap-4">
<h1>طلبات العضوية</h1>
......@@ -35,7 +36,7 @@
</div>
<?php else: ?>
<!-- Bulk Actions Bar -->
<div class="bulk-actions-bar" id="bulkActionsBar" style="display: none;">
<div class="bulk-actions-bar" id="bulkActionsBar" style="display: none;"<?= $canDecide ? '' : ' data-locked="1"' ?>>
<div class="flex items-center gap-3">
<span class="text-sm"><span id="selectedCount">0</span> محدد</span>
<button type="button" class="btn btn-success btn-sm" onclick="bulkApprove()">
......@@ -102,7 +103,7 @@
<td>
<div class="flex items-center gap-2">
<a href="/org-applications/<?= $app['id'] ?>" class="btn btn-ghost btn-sm">عرض</a>
<?php if (($app['status'] ?? '') === 'pending'): ?>
<?php if ($canDecide && ($app['status'] ?? '') === 'pending'): ?>
<form method="POST" action="/org-applications/<?= $app['id'] ?>/approve" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-success btn-sm" title="قبول" aria-label="قبول" onclick="return confirm('قبول هذا الطلب وإضافة اللاعب للمنظمة؟')">
......@@ -194,6 +195,7 @@ function updateBulkBar() {
const checked = document.querySelectorAll('.row-check:checked');
const bar = document.getElementById('bulkActionsBar');
const count = document.getElementById('selectedCount');
if (bar.dataset.locked) return;
if (checked.length > 0) {
bar.style.display = 'block';
count.textContent = checked.length;
......
<?php $canDecide = !Auth::isOrgScoped() || Auth::hasPermission('org-applications.approve'); // org_manager can look, not decide ?>
<?php
[$statusLabel, $statusBadge] = OrgApplicationsController::STATUS[$application['status']] ?? [$application['status'], 'default'];
$orgName = $organization ? ((($organization['name_ar'] ?? '') ?: $organization['name'])) : null;
......@@ -142,7 +143,9 @@ $fileIcon = '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke=
<?php endif; ?>
</div>
<?php if ($application['status'] === 'pending'): ?>
<?php if ($application['status'] === 'pending' && !$canDecide): ?>
<div class="card"><p class="text-sm text-muted">الطلب بانتظار قرار مدير المنظمة أو إدارة المنصة.</p></div>
<?php elseif ($application['status'] === 'pending'): ?>
<div class="grid grid-2">
<div class="card">
<div class="card-header"><h3 class="card-title">قبول الطلب</h3></div>
......
......@@ -7,6 +7,7 @@
[$sl, $sb] = RedeemService::STATUS[$code['status']];
$f = $stats['funnel'] ?? [];
$csrf = Auth::csrfToken();
$canRevoke = !Auth::isOrgScoped() || Auth::hasPermission('org-invites.delete'); // org_manager can look, not revoke or reverse
$funnel = [
'opens' => 'فتح الرابط', 'previews' => 'معاينات', 'signups' => 'حسابات جديدة', 'redemptions' => 'استخدامات',
'pending' => 'بانتظار الموافقة', 'rejected' => 'مرفوضة', 'reversed' => 'أُلغيت', 'first_game' => 'لعبوا مباراة', 'first_tournament' => 'دخلوا بطولة',
......@@ -87,7 +88,7 @@ $elig = is_string($code['eligibility'] ?? null) ? json_decode($code['eligibility
<td><?= !empty($r['was_new_signup']) ? '<span class="badge badge-success badge-sm">جديد</span>' : '<span class="text-muted text-xs">لا</span>' ?></td>
<td><span class="badge badge-<?= $rb ?> badge-sm"><?= View::e($rl) ?></span><?php if (!empty($r['reverse_reason'])): ?><div class="text-xs text-muted"><?= View::e($r['reverse_reason']) ?></div><?php endif; ?></td>
<td>
<?php if (in_array($r['status'], ['applied', 'partial', 'pending_approval'], true)): ?>
<?php if ($canRevoke && in_array($r['status'], ['applied', 'partial', 'pending_approval'], true)): ?>
<form method="POST" action="<?= $base ?>/<?= $code['id'] ?>/redemptions/<?= $r['id'] ?>/reverse" class="flex gap-2 items-center">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<input type="text" name="reason" class="form-input" style="max-width: 160px; padding: var(--space-1) var(--space-2); font-size: 12px;" placeholder="السبب" required>
......@@ -102,7 +103,7 @@ $elig = is_string($code['eligibility'] ?? null) ? json_decode($code['eligibility
<?php endif; ?>
</div>
<?php if ($code['status'] !== 'revoked'): ?>
<?php if ($canRevoke && $code['status'] !== 'revoked'): ?>
<div class="card">
<h3 class="card-title mb-3">إلغاء الكود</h3>
<form method="POST" action="<?= $base ?>/<?= $code['id'] ?>/revoke" class="flex gap-3 items-end flex-wrap">
......
<div class="content-header">
<h1>التصنيف الموسمي</h1>
</div>
<!-- Filters -->
<div class="card mb-5">
<form method="GET" action="/org-leaderboards/seasonal" class="flex gap-4 items-end flex-wrap">
<div class="form-group" style="margin-bottom:0;">
<label class="form-label">الموسم</label>
<input type="text" name="season" class="form-input" value="<?= View::e($season ?? '') ?>" placeholder="e.g. 2024-Q1" dir="ltr">
</div>
<button type="submit" class="btn btn-primary">تصفية</button>
</form>
</div>
<!-- Seasonal Rankings Table -->
<div class="data-table-wrapper">
<?php if (empty($rankings)): ?>
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 21h8m-4-4v4m-4.5-8.5L12 8l4.5 4.5M3 3h18v4H3z"/></svg>
<h3 class="empty-state-title">لا توجد تصنيفات</h3>
<p class="empty-state-text">لم يتم تسجيل أي تصنيفات موسمية<?= !empty($season) ? ' لهذا الموسم' : '' ?></p>
</div>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<th style="width: 60px;">المركز</th>
<th>المنظمة</th>
<th>النقاط</th>
<th>فوز مباريات</th>
<th>خسارة مباريات</th>
<th>فوز بطولات</th>
<th>فوز تحديات</th>
<th>نشاط الأعضاء</th>
<th>التغيير</th>
</tr>
</thead>
<tbody>
<?php foreach ($rankings as $index => $ranking): ?>
<?php
$rank = $pagination->offset + $index + 1;
$org = $ranking['el3ab_organizations'] ?? null;
$orgName = $org['name_ar'] ?? $org['name'] ?? '-';
$previousRank = $ranking['previous_rank'] ?? null;
$rankChange = $previousRank ? ($previousRank - $rank) : 0;
?>
<tr>
<td>
<?php if ($rank <= 3): ?>
<span class="badge <?= $rank === 1 ? 'badge-warning' : ($rank === 2 ? 'badge-default' : 'badge-info') ?>">#<?= $rank ?></span>
<?php else: ?>
<span class="text-muted">#<?= $rank ?></span>
<?php endif; ?>
</td>
<td>
<div class="flex items-center gap-2">
<?php if (!empty($org['logo_url'])): ?>
<img src="<?= View::e($org['logo_url']) ?>" alt="" style="width:24px;height:24px;border-radius:4px;">
<?php endif; ?>
<span><?= View::e($orgName) ?></span>
</div>
</td>
<td><strong class="tabular-nums"><?= number_format($ranking['total_points'] ?? 0) ?></strong></td>
<td class="tabular-nums"><?= number_format($ranking['matches_won'] ?? 0) ?></td>
<td class="tabular-nums"><?= number_format($ranking['matches_lost'] ?? 0) ?></td>
<td class="tabular-nums"><?= number_format($ranking['tournaments_won'] ?? 0) ?></td>
<td class="tabular-nums"><?= number_format($ranking['challenges_won'] ?? 0) ?></td>
<td class="tabular-nums"><?= number_format($ranking['member_activity_score'] ?? 0) ?></td>
<td>
<?php if ($rankChange > 0): ?>
<span style="color: var(--success);">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;"><polyline points="18 15 12 9 6 15"/></svg>
<?= $rankChange ?>
</span>
<?php elseif ($rankChange < 0): ?>
<span style="color: var(--danger);">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;"><polyline points="6 9 12 15 18 9"/></svg>
<?= abs($rankChange) ?>
</span>
<?php elseif ($previousRank !== null): ?>
<span class="text-muted">-</span>
<?php else: ?>
<span class="badge badge-info">جديد</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="table-footer">
<span><?= $pagination->rangeText() ?></span>
<div class="pagination">
<?php foreach ($pagination->pages() as $p): ?>
<a href="?page=<?= $p ?>&season=<?= urlencode($season ?? '') ?>" class="pagination-btn <?= $p === $pagination->page ? 'active' : '' ?>"><?= $p ?></a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<?php
/**
* Organisations (el3ab_organizations), managed only here: the mobile app has
* no org management (owner decision 2026-10-26). Settings, logo and banner,
* capacity tier, verification and suspension, members (roles, kick, mute,
* ban), and reports against an org. Applications, invite codes and
* announcements have their own modules, linked from the org page.
*
* Org model: El3ab docs/redeem-contract.md 2.1, 2.2. member_count is kept by a
* trigger; a member who leaves is status 'left', never deleted.
*/
class OrganizationsController
{
private Database $db;
public const HUES = [
'gold' => 'ذهبي', 'silver' => 'فضي', 'red' => 'أحمر', 'green' => 'أخضر', 'blue' => 'أزرق', 'purple' => 'بنفسجي',
'orange' => 'برتقالي', 'cream' => 'كريمي', 'teal' => 'فيروزي', 'wood' => 'خشبي', 'pink' => 'وردي',
];
public const POLICIES = [
'open' => 'مفتوحة: ينضم أي لاعب مباشرة',
'approval' => 'بموافقة: يقدم اللاعب طلبًا',
'code_only' => 'بالكود فقط',
'closed' => 'مغلقة: لا انضمام إلا بقرار الإدارة',
];
public const GAMES = ['chess' => 'شطرنج', 'ludo' => 'لودو', 'domino' => 'دومينو', 'backgammon' => 'طاولة'];
/** Capacity tiers (design A4): free 500, event 5,000, partner unlimited. */
public const TIERS = ['free' => ['مجانية', 500], 'event' => ['فعالية', 5000], 'partner' => ['شريك', null]];
public const ROLES = ['owner' => 'مالك', 'admin' => 'مدير', 'arbiter' => 'حكم', 'moderator' => 'مشرف', 'member' => 'عضو'];
public const MEMBER_STATUS = ['active' => ['نشط', 'success'], 'suspended' => ['معلق', 'warning'], 'banned' => ['محظور', 'danger'], 'left' => ['غادر', 'default']];
public const REPORT_REASONS = ['impersonation' => 'انتحال', 'scam' => 'احتيال', 'inappropriate' => 'محتوى غير لائق', 'spam' => 'إزعاج', 'cheating' => 'غش', 'other' => 'أخرى'];
public const REPORT_STATUS = ['open' => ['جديد', 'warning'], 'reviewing' => ['قيد المراجعة', 'info'], 'actioned' => ['اتُّخذ إجراء', 'success'], 'dismissed' => ['مرفوض', 'default']];
/** org_activity_log actions in plain Arabic; anything else shows as stored. */
public const ACTIVITY = [
'invite.create' => 'كود دعوة جديد', 'invite.bulk_create' => 'أكواد دعوة بالجملة', 'invite.revoke' => 'إلغاء كود دعوة',
'redemption.reversed' => 'إلغاء استخدام كود', 'member.joined_via_code' => 'انضمام بكود', 'member.joined_via_application' => 'انضمام بطلب',
'application.approved' => 'قبول طلب عضوية', 'application.rejected' => 'رفض طلب عضوية',
'member.role' => 'تغيير دور', 'member.kick' => 'إخراج عضو', 'member.mute' => 'كتم عضو', 'member.unmute' => 'إلغاء كتم',
'member.ban' => 'حظر عضو', 'member.unban' => 'رفع حظر', 'member.reactivate' => 'إعادة عضو', 'member.added' => 'إضافة عضو',
'org.settings' => 'تعديل الإعدادات', 'org.verified' => 'توثيق', 'org.unverified' => 'إزالة التوثيق',
'org.suspended' => 'إيقاف المنظمة', 'org.unsuspended' => 'رفع الإيقاف', 'announcement.published' => 'نشر إعلان',
];
/** The player-wide membership cap (contract 2.2). */
private const ORG_LIMIT = 5;
public function __construct()
{
$this->db = Database::getInstance();
}
public function list(array $params, string $method): void
private static function platformStaff(): bool
{
$search = $_GET['search'] ?? '';
$sort = $_GET['sort'] ?? 'created_at';
$dir = $_GET['dir'] ?? 'desc';
$filter = $_GET['filter'] ?? '';
return !Auth::isOrgScoped() || in_array(Auth::role(), ['superadmin', 'admin'], true);
}
$queryParams = ['select' => '*', 'order' => "{$sort}.{$dir}"];
private static function orgName(array $org): string
{
return (($org['name_ar'] ?? '') ?: $org['name']);
}
if ($search) {
$queryParams['or'] = "(name.ilike.*{$search}*,name_ar.ilike.*{$search}*,slug.ilike.*{$search}*)";
private function load(string $id): array
{
$org = preg_match('/^[0-9a-f-]{36}$/i', $id) ? $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$id}"]) : null;
if (!$org) {
http_response_code(404);
View::render('errors/404');
exit;
}
return $org;
}
// ─── Directory ──────────────────────────────────────────────────────
public function list(array $params, string $method): void
{
$search = trim($_GET['search'] ?? '');
$sort = in_array($_GET['sort'] ?? '', ['created_at', 'name', 'member_count'], true) ? $_GET['sort'] : 'created_at';
$dir = ($_GET['dir'] ?? 'desc') === 'asc' ? 'asc' : 'desc';
$filter = $_GET['filter'] ?? '';
if ($filter === 'verified') {
$queryParams['is_verified'] = 'eq.true';
} elseif ($filter === 'unverified') {
$queryParams['is_verified'] = 'eq.false';
$queryParams = ['select' => '*', 'order' => "{$sort}.{$dir}"];
if ($search !== '') {
$s = str_replace(['*', ',', '(', ')'], '', $search);
$queryParams['or'] = "(name.ilike.*{$s}*,name_ar.ilike.*{$s}*,slug.ilike.*{$s}*,tag.ilike.*{$s}*)";
}
if ($filter === 'verified') $queryParams['is_verified'] = 'eq.true';
elseif ($filter === 'unverified') $queryParams['is_verified'] = 'eq.false';
elseif ($filter === 'suspended') $queryParams['suspended_at'] = 'not.is.null';
elseif ($filter === 'active') $queryParams['is_active'] = 'eq.true';
$countParams = $queryParams;
unset($countParams['select'], $countParams['order']);
$total = $this->db->count('el3ab_organizations', $countParams);
$pagination = Pagination::fromRequest($total);
$queryParams['offset'] = $pagination->offset;
$queryParams['limit'] = $pagination->perPage;
// The view expects $organizations; this used to pass a variable that
// did not exist, so the directory was always empty.
$organizations = $this->db->select('el3ab_organizations', $queryParams);
$openReports = $this->db->count('org_reports', ['status' => 'in.(open,reviewing)']);
$pageTitle = 'المنظمات';
$moduleCSS = 'el3ab_organizations';
$moduleCSS = 'el3ab_organizations'; // unchanged: the org pages never loaded a module stylesheet, and their look stays as it was
$moduleJS = 'el3ab_organizations';
View::render('organizations/list', compact('el3ab_organizations', 'pagination', 'search', 'sort', 'dir', 'filter', 'pageTitle', 'moduleCSS', 'moduleJS'));
View::render('organizations/list', compact('organizations', 'pagination', 'search', 'sort', 'dir', 'filter', 'openReports', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
public function show(array $params, string $method): void
{
$id = $params['id'];
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$id}"]);
$org = $this->load($params['id']);
$id = $org['id'];
if (!$org) {
http_response_code(404);
View::render('errors/404');
return;
$members = $this->members_($id, $_GET['mstatus'] ?? 'active');
$memberCounts = [];
foreach (array_keys(self::MEMBER_STATUS) as $st) {
$memberCounts[$st] = $this->db->count('org_members', ['org_id' => "eq.{$id}", 'status' => "eq.{$st}"]);
}
$members = $this->db->select('org_members', [
'select' => '*',
'org_id' => "eq.{$id}",
'order' => 'joined_at.desc',
]);
$tournaments = $this->db->select('el3ab_tournaments', [
'select' => '*',
'org_id' => "eq.{$id}",
'order' => 'created_at.desc',
'limit' => 20,
'select' => 'id,name,name_ar,game_key,status,max_players,starts_at',
'org_id' => "eq.{$id}", 'order' => 'created_at.desc', 'limit' => 20,
]);
$pageTitle = $org['name_ar'] ?? $org['name'];
$moduleCSS = 'el3ab_organizations';
$pendingApps = $this->db->count('org_membership_applications', ['org_id' => "eq.{$id}", 'status' => 'eq.pending']);
$liveCodes = $this->db->count('redeem_codes', ['org_id' => "eq.{$id}", 'revoked_at' => 'is.null']);
$announcementsCount = $this->db->count('org_announcements', ['org_id' => "eq.{$id}"]);
$reports = $this->db->select('org_reports', ['select' => '*', 'org_id' => "eq.{$id}", 'order' => 'created_at.desc', 'limit' => 50]);
$reporters = $this->profiles(array_column($reports, 'reporter_id'));
$activity = $this->db->select('org_activity_log', ['select' => '*', 'org_id' => "eq.{$id}", 'order' => 'created_at.desc', 'limit' => 30]);
$canPlatform = self::platformStaff();
$tab = $_GET['tab'] ?? 'info';
$pageTitle = self::orgName($org);
$moduleCSS = 'el3ab_organizations'; // unchanged: the org pages never loaded a module stylesheet, and their look stays as it was
$moduleJS = 'el3ab_organizations';
View::render('organizations/show', compact('org', 'members', 'tournaments', 'pageTitle', 'moduleCSS', 'moduleJS'));
View::render('organizations/show', compact(
'org', 'members', 'memberCounts', 'tournaments', 'pendingApps', 'liveCodes', 'announcementsCount',
'reports', 'reporters', 'activity', 'canPlatform', 'tab', 'pageTitle', 'moduleCSS', 'moduleJS'
));
}
// ─── Create / settings ──────────────────────────────────────────────
public function create(array $params, string $method): void
{
$pageTitle = 'إنشاء منظمة';
$org = [];
$moduleCSS = 'el3ab_organizations';
View::render('organizations/form', compact('org', 'pageTitle', 'moduleCSS'));
$canPlatform = self::platformStaff();
$moduleCSS = 'el3ab_organizations'; // unchanged: the org pages never loaded a module stylesheet, and their look stays as it was
View::render('organizations/form', compact('org', 'canPlatform', 'pageTitle', 'moduleCSS'));
}
public function store(array $params, string $method): void
{
Auth::requireCsrf();
$data = $this->settingsFromForm(null, true);
if (!is_string($data)) $data = $this->checkImage('logo') ?? $this->checkImage('banner') ?? $data;
if (is_string($data)) {
Response::error($data, '/organizations/create');
return;
}
$validator = Validator::make($_POST)
->required('name', 'اسم المنظمة')
->minLength('name', 2, 'اسم المنظمة')
->maxLength('name', 100, 'اسم المنظمة')
->required('slug', 'المعرف')
->minLength('slug', 2, 'المعرف');
if ($validator->fails()) {
Response::error($validator->firstError(), '/organizations/create');
$owner = null;
if (($ownerKey = trim((string)($_POST['owner'] ?? ''))) !== '') {
$owner = $this->findPlayer($ownerKey);
if (!$owner) {
Response::error('لم يُعثر على اللاعب المالك', '/organizations/create');
return;
}
}
$data = [
'name' => trim($_POST['name']),
'name_ar' => trim($_POST['name_ar'] ?? ''),
'slug' => trim($_POST['slug']),
'description' => trim($_POST['description'] ?? ''),
'description_ar' => trim($_POST['description_ar'] ?? ''),
'contact_email' => trim($_POST['contact_email'] ?? ''),
'website' => trim($_POST['website'] ?? ''),
'country_code' => trim($_POST['country_code'] ?? ''),
'city' => trim($_POST['city'] ?? ''),
'is_verified' => false,
'is_active' => true,
];
$data['is_active'] = true;
$data['is_verified'] = !empty($_POST['is_verified']);
$org = $this->db->insert('el3ab_organizations', $data);
if (!$org) {
Response::error('لم تُنشأ المنظمة: ' . $this->explain(), '/organizations/create');
return;
}
if (!empty($_FILES['logo']) && $_FILES['logo']['error'] === UPLOAD_ERR_OK) {
$data['logo_url'] = $this->uploadLogo($_FILES['logo']);
$warn = '';
foreach (['logo', 'banner'] as $kind) {
$r = $this->upload($org['id'], $kind);
if ($r === null) continue;
if (is_string($r)) $warn .= " ({$r})";
}
if ($owner) {
$r = $this->setMembership($org, $owner['id'], 'owner', 'admin');
if ($r !== true) $warn .= ' (لم يُضف المالك: ' . $r . ')';
}
$result = $this->db->insert('el3ab_organizations', $data);
AuditLog::log('create', 'organization', $result['id'] ?? null, null, $data);
Response::success('تم إنشاء المنظمة بنجاح', '/organizations');
// The org exists; a failed logo or owner step is reported above, not as a failed create.
$this->db->clearWriteErrors();
AuditLog::log('create', 'organization', $org['id'], null, $data + ['owner' => $owner['id'] ?? null]);
Response::success('تم إنشاء المنظمة' . $warn, "/organizations/{$org['id']}");
}
public function edit(array $params, string $method): void
{
$id = $params['id'];
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$id}"]);
if (!$org) {
http_response_code(404);
View::render('errors/404');
return;
}
$pageTitle = 'تعديل المنظمة';
$moduleCSS = 'el3ab_organizations';
View::render('organizations/form', compact('org', 'pageTitle', 'moduleCSS'));
$org = $this->load($params['id']);
$canPlatform = self::platformStaff();
$pageTitle = 'إعدادات المنظمة';
$moduleCSS = 'el3ab_organizations'; // unchanged: the org pages never loaded a module stylesheet, and their look stays as it was
View::render('organizations/form', compact('org', 'canPlatform', 'pageTitle', 'moduleCSS'));
}
public function update(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$old = $this->load($params['id']);
$id = $old['id'];
$old = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$id}"]);
if (!$old) {
Response::error('المنظمة غير موجودة', '/organizations');
$data = $this->settingsFromForm($old, false);
if (!is_string($data)) $data = $this->checkImage('logo') ?? $this->checkImage('banner') ?? $data;
if (is_string($data)) {
Response::error($data, "/organizations/{$id}/edit");
return;
}
$data['updated_at'] = date('c');
if (!empty($_POST['remove_logo'])) $data['logo_url'] = null;
if (!empty($_POST['remove_banner'])) $data['banner_url'] = null;
$data = [
'name' => trim($_POST['name'] ?? $old['name']),
'name_ar' => trim($_POST['name_ar'] ?? ''),
'slug' => trim($_POST['slug'] ?? $old['slug']),
'description' => trim($_POST['description'] ?? ''),
'description_ar' => trim($_POST['description_ar'] ?? ''),
'contact_email' => trim($_POST['contact_email'] ?? ''),
'website' => trim($_POST['website'] ?? ''),
'country_code' => trim($_POST['country_code'] ?? ''),
'city' => trim($_POST['city'] ?? ''),
'updated_at' => date('c'),
];
if (!empty($_FILES['logo']) && $_FILES['logo']['error'] === UPLOAD_ERR_OK) {
$data['logo_url'] = $this->uploadLogo($_FILES['logo']);
if ($this->db->update('el3ab_organizations', ['id' => "eq.{$id}"], $data) === null) {
Response::error('لم تُحفظ الإعدادات: ' . $this->explain(), "/organizations/{$id}/edit");
return;
}
$warn = '';
foreach (['logo', 'banner'] as $kind) {
$r = $this->upload($id, $kind);
if (is_string($r)) $warn .= " ({$r})";
}
// A replaced or removed image is deleted from storage (best effort).
$now = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$id}", 'select' => 'logo_url,banner_url']) ?? [];
foreach (['logo' => 'org-logos', 'banner' => 'org-banners'] as $kind => $bucket) {
$was = (string)($old[$kind . '_url'] ?? '');
if ($was !== '' && $was !== ($now[$kind . '_url'] ?? null)
&& preg_match('#/storage/v1/object/public/' . $bucket . '/([^?]+)#', $was, $m)) {
try { SupabaseStorage::getInstance()->delete($bucket, rawurldecode($m[1])); } catch (Throwable $e) { /* stays orphaned */ }
}
}
$this->db->update('el3ab_organizations', ['id' => "eq.{$id}"], $data);
$this->db->clearWriteErrors();
$this->activity($id, 'org.settings', 'organization', $id, ['changed' => array_keys(array_diff_assoc(array_map('json_encode', $data), array_map('json_encode', array_intersect_key($old, $data))))]);
AuditLog::log('update', 'organization', $id, $old, $data);
Response::success('تم تحديث بيانات المنظمة', "/organizations/{$id}");
Response::success('تم حفظ إعدادات المنظمة' . $warn, "/organizations/{$id}");
}
public function verify(array $params, string $method): void
/**
* The settings form as el3ab_organizations columns. Tier, capacity and
* verification are platform decisions: an org-scoped account cannot set them.
*
* @return array|string
*/
private function settingsFromForm(?array $old, bool $creating)
{
Auth::requireCsrf();
$id = $params['id'];
$old = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$id}"]);
if (!$old) {
Response::error('المنظمة غير موجودة', '/organizations');
return;
}
$v = Validator::make($_POST)->required('name', 'اسم المنظمة')->minLength('name', 2, 'اسم المنظمة')->maxLength('name', 80, 'اسم المنظمة');
if ($v->fails()) return $v->firstError();
$data = [
'is_verified' => true,
'name' => trim($_POST['name']),
'name_ar' => trim((string)($_POST['name_ar'] ?? '')) ?: null,
'description' => trim((string)($_POST['description'] ?? '')) ?: null,
'description_ar' => trim((string)($_POST['description_ar'] ?? '')) ?: null,
'contact_email' => trim((string)($_POST['contact_email'] ?? '')) ?: null,
'website' => trim((string)($_POST['website'] ?? '')) ?: null,
'country_code' => strtoupper(trim((string)($_POST['country_code'] ?? ''))) ?: null,
'city' => trim((string)($_POST['city'] ?? '')) ?: null,
];
foreach (['name_ar' => 80, 'description' => 1000, 'description_ar' => 1000] as $k => $max) {
if ($data[$k] !== null && mb_strlen($data[$k]) > $max) return "النص أطول من {$max} حرفًا";
}
if ($data['contact_email'] && !filter_var($data['contact_email'], FILTER_VALIDATE_EMAIL)) return 'البريد الإلكتروني غير صالح';
if ($data['website'] && !preg_match('#^https://#i', $data['website'])) return 'الموقع يجب أن يبدأ بـ https://';
$this->db->update('el3ab_organizations', ['id' => "eq.{$id}"], $data);
AuditLog::log('verify', 'organization', $id, $old, $data);
Response::success('تم توثيق المنظمة بنجاح', "/organizations/{$id}");
if ($creating) {
$slug = strtolower(trim((string)($_POST['slug'] ?? '')));
if (!preg_match('/^[a-z0-9][a-z0-9-]{1,49}$/', $slug)) return 'المعرف: حروف إنجليزية صغيرة وأرقام وشرطات (2 إلى 50)';
$data['slug'] = $slug;
}
public function delete(array $params, string $method): void
$tag = trim((string)($_POST['tag'] ?? ''));
if ($tag !== '' && !preg_match('/^\S{2,5}$/u', $tag)) return 'الوسم من 2 إلى 5 أحرف بدون مسافات';
$data['tag'] = $tag !== '' ? $tag : null;
$hue = (string)($_POST['hue'] ?? 'purple');
if (!isset(self::HUES[$hue])) return 'لون الملصق غير معروف';
$data['hue'] = $hue;
$policy = (string)($_POST['join_policy'] ?? 'approval');
if (!isset(self::POLICIES[$policy])) return 'سياسة الانضمام غير معروفة';
$data['join_policy'] = $policy;
$games = array_values(array_intersect(array_keys(self::GAMES), (array)($_POST['games'] ?? [])));
if (!$games) return 'اختر لعبة واحدة على الأقل';
$data['games'] = $games;
$features = is_array($old['features'] ?? null) ? $old['features'] : (json_decode((string)($old['features'] ?? '{}'), true) ?: []);
$features['member_invites'] = !empty($_POST['member_invites']);
$data['features'] = $features;
if (self::platformStaff()) {
$tier = (string)($_POST['tier'] ?? 'free');
if (!isset(self::TIERS[$tier])) return 'الفئة غير معروفة';
$data['tier'] = $tier;
$max = trim((string)($_POST['max_members'] ?? ''));
if ($max === '') {
$data['max_members'] = self::TIERS[$tier][1];
} elseif ($max === 'unlimited') {
$data['max_members'] = null;
} else {
if (!ctype_digit($max) || (int)$max < 1 || (int)$max > 1000000) return 'الحد الأقصى للأعضاء غير صالح';
$data['max_members'] = (int)$max;
}
if ($old && $data['max_members'] !== null && (int)$data['max_members'] < (int)($old['member_count'] ?? 0)) {
return 'الحد الأقصى أقل من عدد الأعضاء الحاليين (' . (int)$old['member_count'] . ')';
}
}
return $data;
}
/**
* Logo or banner to Supabase storage (public buckets org-logos and
* org-banners), never the container's disk, which a redeploy wipes.
* Returns null when no file was sent, true when stored, or an error.
*/
private function upload(string $orgId, string $kind)
{
Auth::requireCsrf();
Auth::requireRole('admin');
$id = $params['id'];
$file = $_FILES[$kind] ?? null;
if (!$file || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return null;
if (($err = $this->checkImage($kind)) !== null) return $err;
$storage = SupabaseStorage::getInstance();
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$ext = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'][$mime] ?? 'img';
$bucket = $kind === 'logo' ? 'org-logos' : 'org-banners';
$path = $orgId . '/' . $kind . '-' . time() . '-' . bin2hex(random_bytes(3)) . '.' . $ext;
try {
$url = $storage->upload($bucket, $path, $file['tmp_name'], $mime);
} catch (RuntimeException $e) {
return 'فشل الرفع: ' . $e->getMessage();
}
$ok = $this->db->update('el3ab_organizations', ['id' => "eq.{$orgId}"], [$kind . '_url' => $url, 'updated_at' => date('c')]);
return $ok === null ? 'رُفعت الصورة لكن لم تُحفظ' : true;
}
$old = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$id}"]);
if (!$old) {
Response::error('المنظمة غير موجودة', '/organizations');
return;
/** Why the sent logo or banner is refused, or null (also when none was sent). */
private function checkImage(string $kind): ?string
{
$file = $_FILES[$kind] ?? null;
if (!$file || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return null;
$label = $kind === 'logo' ? 'الشعار' : 'الغلاف';
$max = $kind === 'logo' ? 2 * 1024 * 1024 : 5 * 1024 * 1024;
if (SupabaseStorage::getInstance()->validateFile($file, ['image/jpeg', 'image/png', 'image/webp'], $max) !== null) {
return "{$label}: JPEG أو PNG أو WebP حتى " . ($max / 1048576) . ' MB';
}
$dim = @getimagesize($file['tmp_name']);
if (!$dim) return "{$label}: تعذر قراءة الصورة";
if ($kind === 'logo' && ($dim[0] < 64 || $dim[1] < 64)) return 'الشعار 64 بكسل على الأقل';
if ($kind === 'banner' && $dim[0] < 600) return 'الغلاف 600 بكسل عرضًا على الأقل';
return null;
}
$this->db->update('el3ab_organizations', ['id' => "eq.{$id}"], [
'is_active' => false,
]);
// ─── Platform decisions ─────────────────────────────────────────────
AuditLog::log('delete', 'organization', $id, $old);
Response::success('تم حذف المنظمة', '/organizations');
public function verify(array $params, string $method): void
{
Auth::requireCsrf();
$this->requirePlatform();
$old = $this->load($params['id']);
$to = empty($old['is_verified']);
$this->db->update('el3ab_organizations', ['id' => "eq.{$old['id']}"], ['is_verified' => $to, 'updated_at' => date('c')]);
$this->activity($old['id'], $to ? 'org.verified' : 'org.unverified', 'organization', $old['id'], []);
AuditLog::log($to ? 'verify' : 'unverify', 'organization', $old['id'], ['is_verified' => !$to], ['is_verified' => $to]);
Response::success($to ? 'وُثقت المنظمة وتظهر بشارة التوثيق' : 'أُزيلت شارة التوثيق', "/organizations/{$old['id']}");
}
public function members(array $params, string $method): void
/** Suspend (deactivates and revokes every live code) or lift a suspension, in one database call. */
public function suspend(array $params, string $method): void
{
$id = $params['id'];
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$id}"]);
if (!$org) {
http_response_code(404);
View::render('errors/404');
Auth::requireCsrf();
$this->requirePlatform();
$org = $this->load($params['id']);
$suspend = ($_POST['action'] ?? 'suspend') === 'suspend';
$reason = trim((string)($_POST['reason'] ?? ''));
$r = $this->db->rpc('org_set_suspended', [
'p_org' => $org['id'], 'p_suspend' => $suspend, 'p_admin' => Auth::user()['id'] ?? null, 'p_reason' => $reason !== '' ? $reason : null,
]);
if (!is_array($r) || empty($r['ok'])) {
$this->db->clearWriteErrors();
$msg = ['forbidden' => 'الإيقاف لإدارة المنصة فقط', 'already_suspended' => 'المنظمة موقوفة بالفعل', 'not_suspended' => 'المنظمة ليست موقوفة', 'reason_required' => 'اكتب سبب الإيقاف'][$r['error'] ?? ''] ?? ('تعذر: ' . ($r['error'] ?? $this->explain()));
Response::error($msg, "/organizations/{$org['id']}");
return;
}
AuditLog::log($suspend ? 'suspend' : 'unsuspend', 'organization', $org['id'], null, ['reason' => $reason, 'codes_revoked' => $r['codes_revoked'] ?? 0]);
Response::success($suspend
? 'أُوقفت المنظمة وأُلغي ' . (int)($r['codes_revoked'] ?? 0) . ' كود'
: 'أُعيد تفعيل المنظمة. الأكواد الملغاة تبقى ملغاة؛ أنشئ أكوادًا جديدة', "/organizations/{$org['id']}");
}
$members = $this->db->select('org_members', [
'select' => '*',
'org_id' => "eq.{$id}",
'order' => 'joined_at.desc',
]);
public function delete(array $params, string $method): void
{
Auth::requireCsrf();
$this->requirePlatform();
$old = $this->load($params['id']);
$this->db->update('el3ab_organizations', ['id' => "eq.{$old['id']}"], ['is_active' => false, 'updated_at' => date('c')]);
AuditLog::log('delete', 'organization', $old['id'], $old);
Response::success('حُذفت المنظمة (حذف ناعم: أكوادها وانضماماتها متوقفة)', '/organizations');
}
$pageTitle = 'أعضاء ' . ($org['name_ar'] ?? $org['name']);
$moduleCSS = 'el3ab_organizations';
$moduleJS = 'el3ab_organizations';
// ─── Members ────────────────────────────────────────────────────────
View::render('organizations/show', compact('org', 'members', 'pageTitle', 'moduleCSS', 'moduleJS'));
public function members(array $params, string $method): void
{
header('Location: /organizations/' . rawurlencode($params['id']) . '?tab=members');
exit;
}
public function addMember(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$org = $this->load($params['id']);
$back = "/organizations/{$org['id']}?tab=members";
$player = $this->findPlayer((string)($_POST['player'] ?? $_POST['user_id'] ?? ''));
if (!$player) {
Response::error('لم يُعثر على اللاعب', $back);
return;
}
$role = (string)($_POST['role'] ?? 'member');
if (!isset(self::ROLES[$role])) $role = 'member';
$r = $this->setMembership($org, $player['id'], $role, 'admin');
if ($r !== true) {
Response::error($r, $back);
return;
}
$this->notify($player['id'], $org, "انضممت إلى {$this->n($org)}", 'أضافتك إدارة المنظمة كـ' . self::ROLES[$role] . '.', 'member_added');
$this->activity($org['id'], 'member.added', 'member', $player['id'], ['role' => $role]);
AuditLog::log('add_member', 'organization', $org['id'], null, ['player_id' => $player['id'], 'role' => $role]);
Response::success('أُضيف ' . ($player['display_name'] ?: $player['username']) . ' كـ' . self::ROLES[$role], $back);
}
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$id}"]);
if (!$org) {
Response::error('المنظمة غير موجودة', '/organizations');
/** Role change, kick, mute, unmute, ban, unban: one endpoint, one member. */
public function memberAction(array $params, string $method): void
{
Auth::requireCsrf();
$org = $this->load($params['id']);
$back = "/organizations/{$org['id']}?tab=members";
$member = $this->db->selectOne('org_members', ['id' => 'eq.' . ($params['memberId'] ?? ''), 'org_id' => "eq.{$org['id']}"]);
if (!$member) {
Response::error('العضو غير موجود', $back);
return;
}
$action = (string)($_POST['action'] ?? '');
$reason = trim((string)($_POST['reason'] ?? ''));
$player = $this->db->selectOne('profiles', ['id' => "eq.{$member['player_id']}", 'select' => 'id,username,display_name']);
$who = $player ? ($player['display_name'] ?: $player['username']) : 'العضو';
// The last active owner cannot be removed, demoted or banned.
if (in_array($action, ['kick', 'ban', 'role'], true) && $member['role'] === 'owner' && $member['status'] === 'active'
&& ($action !== 'role' || ($_POST['role'] ?? '') !== 'owner')) {
$owners = $this->db->count('org_members', ['org_id' => "eq.{$org['id']}", 'role' => 'eq.owner', 'status' => 'eq.active']);
if ($owners <= 1) {
Response::error('هذا آخر مالك للمنظمة؛ عيّن مالكًا آخر أولًا', $back);
return;
}
}
$userId = trim($_POST['user_id'] ?? '');
$role = trim($_POST['role'] ?? 'member');
switch ($action) {
case 'role':
$role = (string)($_POST['role'] ?? '');
if (!isset(self::ROLES[$role])) { Response::error('دور غير صالح', $back); return; }
if ($member['status'] !== 'active') { Response::error('غيّر دور عضو نشط فقط', $back); return; }
if ($member['role'] === $role) { Response::error('هذا دوره الحالي', $back); return; }
$ok = $this->db->update('org_members', ['id' => "eq.{$member['id']}"], ['role' => $role]);
$this->notify($member['player_id'], $org, "دورك في {$this->n($org)}: " . self::ROLES[$role], 'غيرت إدارة المنظمة دورك.', 'member_role');
$msg = "أصبح {$who} " . self::ROLES[$role];
$log = ['role' => $role, 'previous_role' => $member['role']];
break;
case 'kick':
if ($member['status'] !== 'active') { Response::error('العضو ليس نشطًا', $back); return; }
$ok = $this->db->update('org_members', ['id' => "eq.{$member['id']}"], ['status' => 'left', 'left_at' => date('c')]);
$this->notify($member['player_id'], $org, "خرجت من {$this->n($org)}", $reason !== '' ? "السبب: {$reason}" : 'أخرجتك إدارة المنظمة.', 'member_kicked');
$msg = "أُخرج {$who} من المنظمة";
$log = ['reason' => $reason];
break;
case 'mute':
$minutes = (int)($_POST['minutes'] ?? 0);
$until = $minutes > 0 ? gmdate('c', time() + $minutes * 60) : null;
$ok = $this->db->update('org_members', ['id' => "eq.{$member['id']}"], ['is_muted' => true, 'muted_until' => $until]);
$msg = "كُتم {$who}" . ($until ? ' حتى ' . Time::show($until) : ' حتى إلغاء الكتم');
$log = ['minutes' => $minutes ?: null, 'reason' => $reason];
break;
case 'unmute':
$ok = $this->db->update('org_members', ['id' => "eq.{$member['id']}"], ['is_muted' => false, 'muted_until' => null]);
$msg = "أُلغي كتم {$who}";
$log = [];
break;
case 'ban':
if ($reason === '') { Response::error('اكتب سبب الحظر', $back); return; }
$ok = $this->db->update('org_members', ['id' => "eq.{$member['id']}"], ['status' => 'banned', 'ban_reason' => mb_substr($reason, 0, 500), 'left_at' => date('c')]);
// Pending applications to this org are closed too (redeem-api.md 6.4).
$rejected = 0;
foreach ($this->db->select('org_membership_applications', ['select' => 'id', 'org_id' => "eq.{$org['id']}", 'player_id' => "eq.{$member['player_id']}", 'status' => 'eq.pending']) as $app) {
$d = $this->db->rpc('decide_application', ['p_app' => $app['id'], 'p_decision' => 'rejected', 'p_reviewer_admin' => Auth::user()['id'] ?? null, 'p_reason' => 'محظور من المنظمة: ' . $reason]);
if (!empty($d['ok'])) $rejected++;
}
$this->db->clearWriteErrors();
$this->notify($member['player_id'], $org, "حُظرت من {$this->n($org)}", "السبب: {$reason}", 'member_banned');
$msg = "حُظر {$who}" . ($rejected ? " ورُفض {$rejected} طلب معلق" : '');
$log = ['reason' => $reason, 'applications_rejected' => $rejected];
break;
case 'unban':
if ($member['status'] !== 'banned') { Response::error('العضو ليس محظورًا', $back); return; }
$ok = $this->db->update('org_members', ['id' => "eq.{$member['id']}"], ['status' => 'left', 'ban_reason' => null]);
$msg = "رُفع الحظر عن {$who} (يمكنه الانضمام من جديد)";
$log = [];
break;
case 'reactivate':
$r = $this->setMembership($org, $member['player_id'], $member['role'] === 'owner' ? 'owner' : $member['role'], 'admin');
if ($r !== true) { Response::error($r, $back); return; }
$ok = true;
$msg = "أُعيد {$who} عضوًا نشطًا";
$log = [];
break;
default:
Response::error('إجراء غير معروف', $back);
return;
}
if (empty($userId)) {
Response::error('يجب تحديد اللاعب', "/organizations/{$id}");
if ($ok === null) {
Response::error('لم يتم: ' . $this->explain(), $back);
return;
}
$this->activity($org['id'], 'member.' . $action, 'member', $member['player_id'], $log + ['member_id' => $member['id']]);
AuditLog::log('member_' . $action, 'organization', $org['id'], ['role' => $member['role'], 'status' => $member['status']], $log + ['player_id' => $member['player_id']]);
Response::success($msg, $back);
}
$existing = $this->db->selectOne('org_members', [
'org_id' => "eq.{$id}",
'player_id' => "eq.{$userId}",
]);
/** Old route: removing a member is a kick (status left), never a delete. */
public function removeMember(array $params, string $method): void
{
$_POST['action'] = 'kick';
$this->memberAction($params, $method);
}
/**
* Insert, reactivate or re-role a membership, with the same refusals the
* database functions apply: banned, org full, org inactive, the 5-org cap.
*
* @return true|string
*/
private function setMembership(array $org, string $playerId, string $role, string $via)
{
if (empty($org['is_active'])) return 'المنظمة موقوفة';
$existing = $this->db->selectOne('org_members', ['org_id' => "eq.{$org['id']}", 'player_id' => "eq.{$playerId}"]);
if ($existing && $existing['status'] === 'banned') return 'اللاعب محظور من هذه المنظمة؛ ارفع الحظر أولًا';
if ($existing && $existing['status'] === 'active') {
if ($existing['role'] === $role) return 'اللاعب عضو بالفعل بهذا الدور';
return $this->db->update('org_members', ['id' => "eq.{$existing['id']}"], ['role' => $role]) === null ? $this->explain() : true;
}
$fresh = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$org['id']}", 'select' => 'member_count,max_members']);
if ($fresh && $fresh['max_members'] !== null && (int)$fresh['member_count'] >= (int)$fresh['max_members']) return 'المنظمة وصلت للحد الأقصى من الأعضاء';
$active = $this->db->count('org_members', ['player_id' => "eq.{$playerId}", 'status' => 'eq.active']);
if ($active >= self::ORG_LIMIT) return 'اللاعب عضو بالفعل في ' . self::ORG_LIMIT . ' منظمات (الحد الأقصى)';
if ($existing) {
Response::error('هذا اللاعب عضو بالفعل', "/organizations/{$id}");
return;
$ok = $this->db->update('org_members', ['id' => "eq.{$existing['id']}"], ['status' => 'active', 'role' => $role, 'left_at' => null, 'joined_at' => date('c'), 'joined_via' => $via]);
} else {
$ok = $this->db->insert('org_members', ['org_id' => $org['id'], 'player_id' => $playerId, 'role' => $role, 'status' => 'active', 'joined_via' => $via]);
}
return $ok === null ? $this->explain() : true;
}
$data = [
'org_id' => $id,
'player_id' => $userId,
'role' => $role,
];
private function members_(string $orgId, string $status): array
{
$q = ['select' => '*', 'org_id' => "eq.{$orgId}", 'order' => 'joined_at.desc', 'limit' => 500];
if (isset(self::MEMBER_STATUS[$status])) $q['status'] = "eq.{$status}";
$rows = $this->db->select('org_members', $q);
$profiles = $this->profiles(array_column($rows, 'player_id'));
$rank = ['owner' => 4, 'admin' => 3, 'arbiter' => 2, 'moderator' => 2, 'member' => 1];
foreach ($rows as &$m) $m['player'] = $profiles[$m['player_id']] ?? null;
unset($m);
usort($rows, fn($a, $b) => ($rank[$b['role']] ?? 1) <=> ($rank[$a['role']] ?? 1));
return $rows;
}
// ─── Reports (H3) ───────────────────────────────────────────────────
$this->db->insert('org_members', $data);
AuditLog::log('add_member', 'organization', $id, null, $data);
Response::success('تم إضافة العضو بنجاح', "/organizations/{$id}");
/** Every report, across orgs (platform staff). */
public function reports(array $params, string $method): void
{
$this->requirePlatform();
$status = $_GET['status'] ?? 'open';
$q = ['select' => '*', 'order' => 'created_at.desc', 'limit' => 200];
if (isset(self::REPORT_STATUS[$status])) $q['status'] = "eq.{$status}";
$reports = $this->db->select('org_reports', $q);
$orgs = [];
$ids = array_values(array_unique(array_column($reports, 'org_id')));
if ($ids) foreach ($this->db->select('el3ab_organizations', ['select' => 'id,name,name_ar,is_active,suspended_at', 'id' => 'in.(' . implode(',', $ids) . ')']) as $o) $orgs[$o['id']] = $o;
$reporters = $this->profiles(array_column($reports, 'reporter_id'));
$pageTitle = 'بلاغات المنظمات';
$moduleCSS = 'el3ab_organizations'; // unchanged: the org pages never loaded a module stylesheet, and their look stays as it was
View::render('organizations/reports', compact('reports', 'orgs', 'reporters', 'status', 'pageTitle', 'moduleCSS'));
}
public function removeMember(array $params, string $method): void
public function reportUpdate(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$memberId = $params['member_id'] ?? ($_POST['member_id'] ?? '');
if (empty($memberId)) {
Response::error('يجب تحديد العضو', "/organizations/{$id}");
$this->requirePlatform();
$report = $this->db->selectOne('org_reports', ['id' => 'eq.' . ($params['reportId'] ?? '')]);
$back = $_POST['back'] ?? '/organizations/reports';
if (!is_string($back) || !str_starts_with($back, '/organizations')) $back = '/organizations/reports';
if (!$report) {
Response::error('البلاغ غير موجود', $back);
return;
}
$member = $this->db->selectOne('org_members', [
'org_id' => "eq.{$id}",
'id' => "eq.{$memberId}",
$status = (string)($_POST['status'] ?? '');
if (!isset(self::REPORT_STATUS[$status])) {
Response::error('حالة غير صالحة', $back);
return;
}
$resolution = trim((string)($_POST['resolution'] ?? ''));
$ok = $this->db->update('org_reports', ['id' => "eq.{$report['id']}"], [
'status' => $status, 'resolution' => $resolution !== '' ? mb_substr($resolution, 0, 1000) : null,
'reviewed_by_admin' => Auth::user()['id'] ?? null, 'reviewed_at' => date('c'),
]);
if (!$member) {
Response::error('العضو غير موجود', "/organizations/{$id}");
if ($ok === null) {
Response::error('لم يُحفظ: ' . $this->explain(), $back);
return;
}
AuditLog::log('report_' . $status, 'org_report', $report['id'], ['status' => $report['status']], ['status' => $status, 'resolution' => $resolution]);
Response::success('حُدّث البلاغ: ' . self::REPORT_STATUS[$status][0], $back);
}
$this->db->delete('org_members', [
'org_id' => "eq.{$id}",
'id' => "eq.{$memberId}",
]);
// ─── Helpers ────────────────────────────────────────────────────────
private function requirePlatform(): void
{
if (!self::platformStaff()) {
http_response_code(403);
View::render('errors/403');
exit;
}
}
private function n(array $org): string
{
return self::orgName($org);
}
AuditLog::log('remove_member', 'organization', $id, $member);
Response::success('تم إزالة العضو', "/organizations/{$id}");
/** The player's bell (title_ar/body_ar, as the game reads them). Best effort. */
private function notify(string $playerId, array $org, string $titleAr, string $bodyAr, string $event): void
{
$this->db->soft(fn(Database $d) => $d->insert('notifications', [
'user_id' => $playerId,
'type' => 'org_' . $event,
'title' => $org['name'],
'title_ar' => $titleAr,
'body' => $bodyAr,
'body_ar' => $bodyAr,
'data' => ['org_id' => $org['id']],
]));
}
private function uploadLogo(array $file): string
/** org_activity_log: actor_id is not null with no FK; for staff it holds the admin id. */
private function activity(string $orgId, string $action, string $type, ?string $resourceId, array $details): void
{
$allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'];
if (!in_array($file['type'], $allowed)) {
return '';
$adminId = Auth::user()['id'] ?? '00000000-0000-0000-0000-000000000000';
$this->db->soft(fn(Database $d) => $d->insert('org_activity_log', [
'org_id' => $orgId, 'actor_id' => $adminId, 'action' => $action,
'resource_type' => $type, 'resource_id' => $resourceId, 'details' => $details + ['by_admin' => true],
]));
}
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = 'org_' . uniqid() . '.' . $ext;
$destination = STORAGE_PATH . '/logos/' . $filename;
private function profiles(array $ids): array
{
$ids = array_values(array_unique(array_filter($ids)));
$out = [];
foreach (array_chunk($ids, 150) as $chunk) {
foreach ($this->db->select('profiles', ['select' => 'id,username,display_name,avatar_url,level,created_at', 'id' => 'in.(' . implode(',', $chunk) . ')']) as $p) $out[$p['id']] = $p;
}
return $out;
}
if (!is_dir(dirname($destination))) {
mkdir(dirname($destination), 0755, true);
private function findPlayer(string $key): ?array
{
$key = ltrim(trim($key), '@');
if ($key === '') return null;
if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $key)) {
return $this->db->selectOne('profiles', ['id' => "eq.{$key}", 'select' => 'id,username,display_name']);
}
if (!preg_match('/^[\p{L}\p{N}_.\-]{2,40}$/u', $key)) return null;
return $this->db->selectOne('profiles', ['username' => 'ilike.' . $key, 'select' => 'id,username,display_name']);
}
move_uploaded_file($file['tmp_name'], $destination);
return '/storage/logos/' . $filename;
/** The database's last refusal, with the two unique keys in plain Arabic. */
private function explain(): string
{
$e = $this->db->lastError();
$msg = $e['message'] ?? '';
if (($e['code'] ?? '') === '23505' && str_contains($msg, 'tag')) return 'الوسم مستخدم لمنظمة أخرى';
if (($e['code'] ?? '') === '23505' && str_contains($msg, 'slug')) return 'المعرف مستخدم لمنظمة أخرى';
return $this->db->lastErrorMessage();
}
}
<?php $isEdit = !empty($org['id']); ?>
<?php
$isEdit = !empty($org['id']);
$games = $org['games'] ?? ['chess'];
if (is_string($games)) $games = array_filter(explode(',', trim($games, '{}')));
$features = $org['features'] ?? [];
if (is_string($features)) $features = json_decode($features, true) ?: [];
$tier = $org['tier'] ?? 'free';
$maxMembers = array_key_exists('max_members', $org) ? $org['max_members'] : 500;
?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/organizations" class="btn btn-icon btn-ghost">
<a href="<?= $isEdit ? '/organizations/' . $org['id'] : '/organizations' ?>" class="btn btn-icon btn-ghost" aria-label="رجوع">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<h1><?= $isEdit ? 'تعديل المنظمة' : 'إنشاء منظمة' ?></h1>
<h1><?= $isEdit ? 'إعدادات المنظمة' : 'إنشاء منظمة' ?></h1>
</div>
</div>
......@@ -16,31 +24,101 @@
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">اسم المنظمة (English) *</label>
<input type="text" name="name" class="form-input" value="<?= View::e($org['name'] ?? '') ?>" required minlength="2" maxlength="100" placeholder="Organization Name">
<input type="text" name="name" class="form-input" value="<?= View::e($org['name'] ?? '') ?>" required minlength="2" maxlength="80" placeholder="Organization Name">
<span class="form-error"></span>
</div>
<div class="form-group">
<label class="form-label">اسم المنظمة (عربي)</label>
<input type="text" name="name_ar" class="form-input" value="<?= View::e($org['name_ar'] ?? '') ?>" placeholder="اسم المنظمة بالعربي">
<input type="text" name="name_ar" class="form-input" value="<?= View::e($org['name_ar'] ?? '') ?>" maxlength="80" placeholder="اسم المنظمة بالعربي">
</div>
</div>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">المعرف (Slug) *</label>
<input type="text" name="slug" class="form-input" value="<?= View::e($org['slug'] ?? '') ?>" required minlength="2" maxlength="50" placeholder="organization-slug" dir="ltr">
<span class="form-hint">معرف فريد للمنظمة (حروف إنجليزية صغيرة وشرطات فقط)</span>
<span class="form-error"></span>
<label class="form-label">المعرف (Slug) <?= $isEdit ? '' : '*' ?></label>
<?php if ($isEdit): ?>
<input type="text" class="form-input" value="<?= View::e($org['slug'] ?? '') ?>" dir="ltr" disabled>
<span class="form-hint">لا يتغير بعد الإنشاء</span>
<?php else: ?>
<input type="text" name="slug" class="form-input" required minlength="2" maxlength="50" pattern="[a-z0-9][a-z0-9-]{1,49}" placeholder="organization-slug" dir="ltr">
<span class="form-hint">حروف إنجليزية صغيرة وأرقام وشرطات</span>
<?php endif; ?>
</div>
<div class="form-group">
<label class="form-label">الوسم (Tag)</label>
<input type="text" name="tag" class="form-input" value="<?= View::e($org['tag'] ?? '') ?>" minlength="2" maxlength="5" pattern="\S{2,5}" placeholder="CCC" dir="ltr">
<span class="form-hint">من 2 إلى 5 أحرف، يظهر بجوار أسماء الأعضاء</span>
</div>
</div>
<div class="form-group">
<label class="form-label">الوصف (عربي)</label>
<textarea name="description_ar" class="form-input" rows="3" maxlength="1000" placeholder="وصف المنظمة بالعربي"><?= View::e($org['description_ar'] ?? '') ?></textarea>
</div>
<div class="form-group">
<label class="form-label">الوصف (English)</label>
<textarea name="description" class="form-input" rows="3" placeholder="Description in English"><?= View::e($org['description'] ?? '') ?></textarea>
<textarea name="description" class="form-input" rows="3" maxlength="1000" placeholder="Description in English" dir="ltr"><?= View::e($org['description'] ?? '') ?></textarea>
</div>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">الوصف (عربي)</label>
<textarea name="description_ar" class="form-input" rows="3" placeholder="وصف المنظمة بالعربي"><?= View::e($org['description_ar'] ?? '') ?></textarea>
<label class="form-label">لون الملصق</label>
<select name="hue" class="form-select">
<?php foreach (OrganizationsController::HUES as $k => $l): ?>
<option value="<?= $k ?>" <?= ($org['hue'] ?? 'purple') === $k ? 'selected' : '' ?>><?= $l ?></option>
<?php endforeach; ?>
</select>
<span class="form-hint">من عائلات ملصقات اللعبة، وليس لونًا حرًا</span>
</div>
<div class="form-group">
<label class="form-label">سياسة الانضمام</label>
<select name="join_policy" class="form-select">
<?php foreach (OrganizationsController::POLICIES as $k => $l): ?>
<option value="<?= $k ?>" <?= ($org['join_policy'] ?? 'approval') === $k ? 'selected' : '' ?>><?= $l ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">الألعاب</label>
<div class="flex gap-4 flex-wrap">
<?php foreach (OrganizationsController::GAMES as $k => $l): ?>
<label class="flex items-center gap-2"><input type="checkbox" name="games[]" value="<?= $k ?>" <?= in_array($k, $games, true) ? 'checked' : '' ?>> <span><?= $l ?></span></label>
<?php endforeach; ?>
</div>
</div>
<label class="flex items-center gap-2 mb-4">
<input type="checkbox" name="member_invites" value="1" <?= !empty($features['member_invites']) ? 'checked' : '' ?>>
<span>يسمح للأعضاء بإنشاء أكواد دعوة شخصية</span>
</label>
<?php if ($canPlatform): ?>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">الفئة</label>
<select name="tier" class="form-select">
<?php foreach (OrganizationsController::TIERS as $k => [$l, $cap]): ?>
<option value="<?= $k ?>" <?= $tier === $k ? 'selected' : '' ?>><?= $l ?> — <?= $cap === null ? 'بلا حد' : number_format($cap) . ' عضو' ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">الحد الأقصى للأعضاء</label>
<input type="text" name="max_members" class="form-input" value="" placeholder="حسب الفئة (الحالي: <?= $maxMembers === null ? 'بلا حد' : (int)$maxMembers ?>)" dir="ltr">
<span class="form-hint">اتركه فارغًا ليتبع الفئة، أو اكتب رقمًا، أو unlimited</span>
</div>
</div>
<?php if (!$isEdit): ?>
<label class="flex items-center gap-2 mb-4"><input type="checkbox" name="is_verified" value="1"> <span>منظمة موثقة (شارة التوثيق)</span></label>
<div class="form-group">
<label class="form-label">المالك (اسم مستخدم اللاعب)</label>
<input type="text" name="owner" class="form-input" dir="ltr" placeholder="username">
<span class="form-hint">يدير المنظمة من التطبيق كمالك</span>
</div>
<?php endif; ?>
<?php endif; ?>
<div class="grid grid-2 gap-4">
<div class="form-group">
......@@ -57,37 +135,40 @@
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">الدولة</label>
<input type="text" name="country_code" class="form-input" value="<?= View::e($org['country_code'] ?? '') ?>" maxlength="2" placeholder="SA">
<input type="text" name="country_code" class="form-input" value="<?= View::e(trim((string)($org['country_code'] ?? ''))) ?>" maxlength="2" placeholder="EG" dir="ltr">
</div>
<div class="form-group">
<label class="form-label">المدينة</label>
<input type="text" name="city" class="form-input" value="<?= View::e($org['city'] ?? '') ?>" placeholder="الرياض">
<input type="text" name="city" class="form-input" value="<?= View::e($org['city'] ?? '') ?>" placeholder="القاهرة">
</div>
</div>
<?php foreach (['logo' => ['شعار المنظمة', 'JPEG أو PNG أو WebP، حتى 2MB، 64 بكسل على الأقل'], 'banner' => ['غلاف المنظمة', 'JPEG أو PNG أو WebP، حتى 5MB، عرض 600 بكسل على الأقل']] as $kind => [$label, $hint]): ?>
<div class="form-group">
<label class="form-label">شعار المنظمة</label>
<div class="file-upload-area" id="logoUploadArea">
<?php if (!empty($org['logo_url'])): ?>
<label class="form-label"><?= $label ?></label>
<div class="file-upload-area">
<?php if (!empty($org[$kind . '_url'])): ?>
<div class="file-upload-preview mb-3">
<img src="<?= View::e($org['logo_url']) ?>" alt="الشعار الحالي" class="org-logo-preview">
<img src="<?= View::e($org[$kind . '_url']) ?>" alt="<?= $label ?> الحالي" class="org-logo-preview">
</div>
<label class="flex items-center gap-2 text-sm mb-2"><input type="checkbox" name="remove_<?= $kind ?>" value="1"> <span>إزالة الصورة الحالية</span></label>
<?php endif; ?>
<input type="file" name="logo" id="logoInput" accept="image/jpeg,image/png,image/webp,image/svg+xml" class="file-input">
<input type="file" name="<?= $kind ?>" accept="image/jpeg,image/png,image/webp" class="file-input">
<div class="file-upload-content">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<p class="text-sm text-secondary mt-2">اسحب الصورة هنا أو اضغط للاختيار</p>
<p class="text-xs text-muted">PNG, JPG, WebP, SVG - حد أقصى 2MB</p>
<p class="text-xs text-muted"><?= $hint ?></p>
</div>
</div>
</div>
<?php endforeach; ?>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">
<span class="btn-text"><?= $isEdit ? 'حفظ التعديلات' : 'إنشاء المنظمة' ?></span>
<span class="btn-text"><?= $isEdit ? 'حفظ الإعدادات' : 'إنشاء المنظمة' ?></span>
<span class="btn-spinner"></span>
</button>
<a href="/organizations" class="btn btn-ghost">إلغاء</a>
<a href="<?= $isEdit ? '/organizations/' . $org['id'] : '/organizations' ?>" class="btn btn-ghost">إلغاء</a>
</div>
</form>
</div>
......@@ -11,6 +11,8 @@
<a href="/organizations" class="filter-pill <?= empty($filter) ? 'active' : '' ?>">الكل</a>
<a href="/organizations?filter=verified" class="filter-pill <?= $filter === 'verified' ? 'active' : '' ?>">موثقة</a>
<a href="/organizations?filter=unverified" class="filter-pill <?= $filter === 'unverified' ? 'active' : '' ?>">غير موثقة</a>
<a href="/organizations?filter=suspended" class="filter-pill <?= $filter === 'suspended' ? 'active' : '' ?>">موقوفة</a>
<a href="/organizations/reports" class="filter-pill">البلاغات<?= !empty($openReports) ? ' (' . (int)$openReports . ')' : '' ?></a>
</div>
<div class="data-table-wrapper">
......@@ -41,7 +43,8 @@
<th>الشعار</th>
<th data-sort="name">الاسم</th>
<th data-sort="name_ar">الاسم العربي</th>
<th data-sort="country_code">الدولة</th>
<th>الوسم</th>
<th>الأعضاء</th>
<th>التحقق</th>
<th>الحالة</th>
<th style="width: 60px;">الإجراءات</th>
......@@ -63,14 +66,9 @@
<div class="font-medium"><?= View::e($org['name']) ?></div>
<div class="text-xs text-muted"><?= View::e($org['slug'] ?? '') ?></div>
</td>
<td><?= View::e($org['name_ar'] ?? '-') ?></td>
<td>
<?php if (!empty($org['country_code'])): ?>
<span class="badge badge-default"><?= View::e($org['country_code']) ?></span>
<?php else: ?>
-
<?php endif; ?>
</td>
<td><?= View::e(($org['name_ar'] ?? '') ?: '-') ?></td>
<td><?= !empty($org['tag']) ? '<span class="badge badge-default" dir="ltr">' . View::e($org['tag']) . '</span>' : '-' ?></td>
<td class="tabular-nums"><?= (int)($org['member_count'] ?? 0) ?> / <?= $org['max_members'] === null ? 'بلا حد' : (int)$org['max_members'] ?></td>
<td>
<?php if ($org['is_verified'] ?? false): ?>
<span class="badge badge-success verification-badge">
......@@ -82,10 +80,12 @@
<?php endif; ?>
</td>
<td>
<?php if ($org['is_active'] ?? true): ?>
<?php if (!empty($org['suspended_at'])): ?>
<span class="badge badge-danger badge-dot">موقوفة</span>
<?php elseif ($org['is_active'] ?? true): ?>
<span class="badge badge-success badge-dot">نشطة</span>
<?php else: ?>
<span class="badge badge-danger badge-dot">معطلة</span>
<span class="badge badge-danger badge-dot">محذوفة</span>
<?php endif; ?>
</td>
<td>
......@@ -95,10 +95,9 @@
</button>
<div class="dropdown-menu">
<a href="/organizations/<?= $org['id'] ?>" class="dropdown-item">عرض</a>
<a href="/organizations/<?= $org['id'] ?>/edit" class="dropdown-item">تعديل</a>
<?php if (!($org['is_verified'] ?? false)): ?>
<button class="dropdown-item" onclick="verifyOrg('<?= $org['id'] ?>', '<?= View::e($org['name']) ?>')">توثيق</button>
<?php endif; ?>
<a href="/organizations/<?= $org['id'] ?>/edit" class="dropdown-item">الإعدادات</a>
<a href="/organizations/<?= $org['id'] ?>?tab=members" class="dropdown-item">الأعضاء</a>
<a href="/organizations/<?= $org['id'] ?>/invites" class="dropdown-item">أكواد الدعوة</a>
<div class="dropdown-divider"></div>
<button class="dropdown-item danger" onclick="confirmDelete('/organizations/<?= $org['id'] ?>/delete', '<?= View::e($org['name']) ?>')">حذف</button>
</div>
......
<?php
/**
* Reports against orgs, across the platform (feature H3).
* Expects: $reports, $orgs, $reporters, $status
*/
$csrf = Auth::csrfToken();
?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/organizations" class="btn btn-icon btn-ghost" aria-label="رجوع">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<h1>بلاغات المنظمات</h1>
</div>
</div>
<div class="filter-pills mb-5">
<a href="/organizations/reports?status=all" class="filter-pill <?= $status === 'all' ? 'active' : '' ?>">الكل</a>
<?php foreach (OrganizationsController::REPORT_STATUS as $k => [$l]): ?>
<a href="/organizations/reports?status=<?= $k ?>" class="filter-pill <?= $status === $k ? 'active' : '' ?>"><?= $l ?></a>
<?php endforeach; ?>
</div>
<p class="text-sm text-muted mb-4">يبلّغ اللاعبون عن المنظمات من التطبيق. لإيقاف منظمة (وإلغاء كل أكوادها) افتح صفحتها.</p>
<div class="data-table-wrapper">
<?php if (empty($reports)): ?>
<div class="empty-state"><h3 class="empty-state-title">لا توجد بلاغات</h3><p class="empty-state-text">لا شيء بهذه الحالة</p></div>
<?php else: ?>
<table class="data-table">
<thead><tr><th>المنظمة</th><th>المبلّغ</th><th>السبب</th><th>التفاصيل</th><th>الحالة</th><th>الوقت</th><th>المراجعة</th></tr></thead>
<tbody>
<?php foreach ($reports as $r): ?>
<?php $o = $orgs[$r['org_id']] ?? null; $rp = $reporters[$r['reporter_id'] ?? ''] ?? null; [$rl, $rb] = OrganizationsController::REPORT_STATUS[$r['status']] ?? [$r['status'], 'default']; ?>
<tr>
<td><?php if ($o): ?><a href="/organizations/<?= $o['id'] ?>?tab=reports" class="text-link"><?= View::e((($o['name_ar'] ?? '') ?: $o['name'])) ?></a><?php if (!empty($o['suspended_at'])): ?> <span class="badge badge-danger badge-sm">موقوفة</span><?php endif; ?><?php else: ?>-<?php endif; ?></td>
<td><?= $rp ? View::e($rp['display_name'] ?: $rp['username']) : '-' ?></td>
<td><?= View::e(OrganizationsController::REPORT_REASONS[$r['reason']] ?? $r['reason']) ?></td>
<td class="text-sm"><?= View::e($r['details'] ?? '') ?><?php if (!empty($r['resolution'])): ?><div class="text-xs text-muted">القرار: <?= View::e($r['resolution']) ?></div><?php endif; ?></td>
<td><span class="badge badge-<?= $rb ?> badge-sm"><?= $rl ?></span></td>
<td class="text-xs tabular-nums"><?= Time::show($r['created_at'] ?? null) ?></td>
<td>
<form method="POST" action="/organizations/reports/<?= $r['id'] ?>/update" class="flex gap-1">
<input type="hidden" name="_csrf" value="<?= $csrf ?>"><input type="hidden" name="back" value="/organizations/reports?status=<?= View::e($status) ?>">
<select name="status" class="form-select" style="width:auto; font-size:12px;" aria-label="الحالة"><?php foreach (OrganizationsController::REPORT_STATUS as $k => [$l]): ?><option value="<?= $k ?>" <?= $r['status'] === $k ? 'selected' : '' ?>><?= $l ?></option><?php endforeach; ?></select>
<input type="text" name="resolution" class="form-input" style="width: 150px; padding: var(--space-1) var(--space-2); font-size: 12px;" placeholder="القرار">
<button type="submit" class="btn btn-xs btn-ghost">حفظ</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php
/**
* Expects: $org, $members, $memberCounts, $tournaments, $pendingApps, $liveCodes,
* $announcementsCount, $reports, $reporters, $activity, $canPlatform, $tab
*/
$name = (($org['name_ar'] ?? '') ?: $org['name']);
$csrf = Auth::csrfToken();
$suspended = !empty($org['suspended_at']);
$games = $org['games'] ?? [];
if (is_string($games)) $games = array_filter(explode(',', trim($games, '{}')));
$tabs = ['info' => 'معلومات عامة', 'members' => 'الأعضاء', 'tournaments' => 'البطولات', 'reports' => 'البلاغات', 'activity' => 'السجل'];
if (!isset($tabs[$tab])) $tab = 'info';
$mstatus = $_GET['mstatus'] ?? 'active';
$canMembers = Auth::hasPermission('organizations.members');
$canSettings = Auth::hasPermission('organizations.update');
$roleBadge = ['owner' => 'badge-purple', 'admin' => 'badge-danger', 'arbiter' => 'badge-info', 'moderator' => 'badge-warning', 'member' => 'badge-default'];
?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/organizations" class="btn btn-icon btn-ghost">
<?php if ($canPlatform): ?>
<a href="/organizations" class="btn btn-icon btn-ghost" aria-label="رجوع">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<h1><?= View::e($org['name_ar'] ?? $org['name']) ?></h1>
<?php if ($org['is_verified'] ?? false): ?>
<?php endif; ?>
<h1><?= View::e($name) ?></h1>
<?php if (!empty($org['tag'])): ?><span class="badge badge-default" dir="ltr"><?= View::e($org['tag']) ?></span><?php endif; ?>
<?php if (!empty($org['is_verified'])): ?>
<span class="badge badge-success verification-badge">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
موثقة
</span>
<?php endif; ?>
<?php if (!($org['is_active'] ?? true)): ?>
<span class="badge badge-danger">معطلة</span>
<?php if ($suspended): ?>
<span class="badge badge-danger">موقوفة</span>
<?php elseif (empty($org['is_active'])): ?>
<span class="badge badge-danger">محذوفة</span>
<?php endif; ?>
</div>
<div class="flex gap-3">
<a href="/organizations/<?= $org['id'] ?>/edit" class="btn btn-ghost">تعديل</a>
<?php if (!($org['is_verified'] ?? false)): ?>
<?php if ($canSettings): ?><a href="/organizations/<?= $org['id'] ?>/edit" class="btn btn-ghost">الإعدادات</a><?php endif; ?>
<?php if ($canPlatform): ?>
<form method="POST" action="/organizations/<?= $org['id'] ?>/verify" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-success" onclick="return confirm('هل تريد توثيق هذه المنظمة؟')">توثيق</button>
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button type="submit" class="btn <?= !empty($org['is_verified']) ? 'btn-ghost' : 'btn-success' ?>" onclick="return confirm('<?= !empty($org['is_verified']) ? 'إزالة شارة التوثيق؟' : 'توثيق هذه المنظمة؟' ?>')"><?= !empty($org['is_verified']) ? 'إزالة التوثيق' : 'توثيق' ?></button>
</form>
<?php endif; ?>
</div>
</div>
<!-- Organization Info Card -->
<?php if ($suspended): ?>
<div class="card mb-4 p-4">
<p class="text-sm text-danger">موقوفة منذ <?= Time::show($org['suspended_at']) ?>: <?= View::e($org['suspended_reason'] ?? '') ?>. أكوادها ملغاة ولا يمكن الانضمام إليها.</p>
</div>
<?php endif; ?>
<div class="grid grid-3 mb-6">
<div class="card" style="grid-column: span 1;">
<div class="flex flex-col items-center text-center p-5">
......@@ -33,20 +60,18 @@
<?php if (!empty($org['logo_url'])): ?>
<img src="<?= View::e($org['logo_url']) ?>" alt="<?= View::e($org['name']) ?>">
<?php else: ?>
<span class="org-logo-placeholder"><?= mb_substr($org['name'] ?? '?', 0, 1) ?></span>
<span class="org-logo-placeholder"><?= View::e(mb_substr($org['name'] ?? '?', 0, 1)) ?></span>
<?php endif; ?>
</div>
<h3 class="text-lg font-semibold"><?= View::e($org['name']) ?></h3>
<?php if (!empty($org['name_ar'])): ?>
<p class="text-secondary text-sm"><?= View::e($org['name_ar']) ?></p>
<?php endif; ?>
<?php if (!empty($org['name_ar'])): ?><p class="text-secondary text-sm"><?= View::e($org['name_ar']) ?></p><?php endif; ?>
<div class="flex gap-4 mt-4">
<div class="text-center">
<div class="text-xl font-bold"><?= count($members ?? []) ?></div>
<div class="text-xs text-muted">الأعضاء</div>
<div class="text-xl font-bold tabular-nums"><?= (int)($org['member_count'] ?? 0) ?></div>
<div class="text-xs text-muted">من <?= $org['max_members'] === null ? 'بلا حد' : number_format((int)$org['max_members']) ?> عضو</div>
</div>
<div class="text-center">
<div class="text-xl font-bold"><?= count($tournaments ?? []) ?></div>
<div class="text-xl font-bold tabular-nums"><?= count($tournaments ?? []) ?></div>
<div class="text-xs text-muted">البطولات</div>
</div>
</div>
......@@ -54,239 +79,220 @@
</div>
<div class="card" style="grid-column: span 2;">
<div class="card-header">
<h3 class="card-title">معلومات المنظمة</h3>
</div>
<div class="flex flex-col gap-3 text-sm">
<div class="flex justify-between"><span class="text-secondary">المعرف (Slug)</span><span dir="ltr"><?= View::e($org['slug'] ?? '-') ?></span></div>
<div class="flex justify-between"><span class="text-secondary">البريد الإلكتروني</span><span dir="ltr"><?= View::e($org['contact_email'] ?? '-') ?></span></div>
<div class="flex justify-between"><span class="text-secondary">الموقع</span><span dir="ltr"><?= !empty($org['website']) ? '<a href="' . View::e($org['website']) . '" target="_blank" class="text-primary">' . View::e($org['website']) . '</a>' : '-' ?></span></div>
<div class="flex justify-between"><span class="text-secondary">الدولة</span><span><?= View::e($org['country_code'] ?? '-') ?></span></div>
<div class="flex justify-between"><span class="text-secondary">المدينة</span><span><?= View::e($org['city'] ?? '-') ?></span></div>
<div class="flex justify-between"><span class="text-secondary">تاريخ الإنشاء</span><span class="tabular-nums"><?= !empty($org['created_at']) ? date('Y-m-d', strtotime($org['created_at'])) : '-' ?></span></div>
<div class="card-header"><h3 class="card-title">إدارة المنظمة</h3></div>
<div class="grid grid-3 gap-3 p-4">
<a href="/org-applications?status=pending&org=<?= $org['id'] ?>" class="stat-card" style="text-decoration:none;">
<div class="stat-content"><div class="stat-label">طلبات العضوية المعلقة</div><div class="stat-value tabular-nums"><?= (int)$pendingApps ?></div></div>
</a>
<a href="/organizations/<?= $org['id'] ?>/invites" class="stat-card" style="text-decoration:none;">
<div class="stat-content"><div class="stat-label">أكواد وروابط الدعوة</div><div class="stat-value tabular-nums"><?= (int)$liveCodes ?></div></div>
</a>
<a href="/organizations/<?= $org['id'] ?>/announcements" class="stat-card" style="text-decoration:none;">
<div class="stat-content"><div class="stat-label">إعلانات للأعضاء</div><div class="stat-value tabular-nums"><?= (int)$announcementsCount ?></div></div>
</a>
</div>
<?php if (!empty($org['description']) || !empty($org['description_ar'])): ?>
<div class="mt-4 pt-4 border-t">
<div class="text-xs text-muted mb-1">الوصف</div>
<p class="text-sm"><?= View::e($org['description_ar'] ?? $org['description'] ?? '') ?></p>
<div class="flex flex-col gap-2 text-sm p-4 pt-0">
<div class="flex justify-between"><span class="text-secondary">سياسة الانضمام</span><span><?= View::e(OrganizationsController::POLICIES[$org['join_policy'] ?? ''] ?? ($org['join_policy'] ?? '-')) ?></span></div>
<div class="flex justify-between"><span class="text-secondary">الفئة</span><span><?= View::e(OrganizationsController::TIERS[$org['tier'] ?? 'free'][0] ?? ($org['tier'] ?? '-')) ?></span></div>
<div class="flex justify-between"><span class="text-secondary">لون الملصق</span><span><?= View::e(OrganizationsController::HUES[$org['hue'] ?? ''] ?? ($org['hue'] ?? '-')) ?></span></div>
<div class="flex justify-between"><span class="text-secondary">الألعاب</span><span><?= View::e(implode('، ', array_map(fn($g) => OrganizationsController::GAMES[$g] ?? $g, $games)) ?: '-') ?></span></div>
</div>
<?php endif; ?>
</div>
</div>
<!-- Tabs -->
<div class="card">
<div class="tabs">
<button class="tab active" onclick="switchTab(this, 'infoTab')">معلومات عامة</button>
<button class="tab" onclick="switchTab(this, 'membersTab')">الأعضاء</button>
<button class="tab" onclick="switchTab(this, 'tournamentsTab')">البطولات</button>
<button class="tab" onclick="switchTab(this, 'verificationTab')">التحقق</button>
<?php foreach ($tabs as $k => $label): ?>
<a href="/organizations/<?= $org['id'] ?>?tab=<?= $k ?>" class="tab <?= $tab === $k ? 'active' : '' ?>"><?= $label ?><?= $k === 'members' ? ' (' . (int)($memberCounts['active'] ?? 0) . ')' : '' ?><?= $k === 'reports' && count(array_filter($reports, fn($r) => in_array($r['status'], ['open', 'reviewing'], true))) ? ' (' . count(array_filter($reports, fn($r) => in_array($r['status'], ['open', 'reviewing'], true))) . ')' : '' ?></a>
<?php endforeach; ?>
</div>
<!-- General Info Tab -->
<div id="infoTab" class="tab-content">
<?php if ($tab === 'info'): ?>
<div class="grid grid-2 gap-4 p-4">
<div>
<div class="text-xs text-muted mb-1">الاسم (English)</div>
<div class="font-medium"><?= View::e($org['name'] ?? '-') ?></div>
</div>
<div>
<div class="text-xs text-muted mb-1">الاسم (عربي)</div>
<div class="font-medium"><?= View::e($org['name_ar'] ?? '-') ?></div>
</div>
<div>
<div class="text-xs text-muted mb-1">المعرف</div>
<div class="font-medium" dir="ltr"><?= View::e($org['slug'] ?? '-') ?></div>
</div>
<div>
<div class="text-xs text-muted mb-1">البريد الإلكتروني</div>
<div class="font-medium" dir="ltr"><?= View::e($org['contact_email'] ?? '-') ?></div>
</div>
<div>
<div class="text-xs text-muted mb-1">الموقع الإلكتروني</div>
<div class="font-medium" dir="ltr"><?= View::e($org['website'] ?? '-') ?></div>
</div>
<div>
<div class="text-xs text-muted mb-1">الدولة</div>
<div class="font-medium"><?= View::e($org['country_code'] ?? '-') ?></div>
</div>
<div>
<div class="text-xs text-muted mb-1">المدينة</div>
<div class="font-medium"><?= View::e($org['city'] ?? '-') ?></div>
</div>
<div>
<div class="text-xs text-muted mb-1">الحالة</div>
<div class="font-medium">
<?php if ($org['is_active'] ?? true): ?>
<span class="badge badge-success">نشطة</span>
<?php else: ?>
<span class="badge badge-danger">معطلة</span>
<div><div class="text-xs text-muted mb-1">المعرف</div><div class="font-medium" dir="ltr"><?= View::e($org['slug'] ?? '-') ?></div></div>
<div><div class="text-xs text-muted mb-1">البريد الإلكتروني</div><div class="font-medium" dir="ltr"><?= View::e($org['contact_email'] ?? '-') ?></div></div>
<div><div class="text-xs text-muted mb-1">الموقع الإلكتروني</div><div class="font-medium" dir="ltr"><?= View::e($org['website'] ?? '-') ?></div></div>
<div><div class="text-xs text-muted mb-1">الدولة / المدينة</div><div class="font-medium"><?= View::e(trim(($org['country_code'] ?? '') . ' ' . ($org['city'] ?? '')) ?: '-') ?></div></div>
<div><div class="text-xs text-muted mb-1">تاريخ الإنشاء</div><div class="font-medium tabular-nums"><?= Time::show($org['created_at'] ?? null, 'Y/m/d') ?></div></div>
<div><div class="text-xs text-muted mb-1">دعوات الأعضاء الشخصية</div><div class="font-medium"><?php $f = is_array($org['features'] ?? null) ? $org['features'] : (json_decode((string)($org['features'] ?? '{}'), true) ?: []); echo !empty($f['member_invites']) ? 'مسموحة' : 'غير مسموحة'; ?></div></div>
<div style="grid-column: span 2;"><div class="text-xs text-muted mb-1">الوصف</div><div class="text-sm"><?= View::e(($org['description_ar'] ?? '') ?: ($org['description'] ?? '-')) ?></div></div>
<?php if (!empty($org['banner_url'])): ?>
<div style="grid-column: span 2;"><div class="text-xs text-muted mb-1">الغلاف</div><img src="<?= View::e($org['banner_url']) ?>" alt="غلاف المنظمة" style="max-width: 100%; max-height: 180px; border-radius: var(--radius-md); object-fit: cover;"></div>
<?php endif; ?>
</div>
<?php if ($canPlatform): ?>
<div class="p-4" style="border-top: 1px solid var(--border);">
<h4 class="font-medium mb-3"><?= $suspended ? 'رفع الإيقاف' : 'إيقاف المنظمة' ?></h4>
<form method="POST" action="/organizations/<?= $org['id'] ?>/suspend" class="flex gap-3 items-end flex-wrap">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<input type="hidden" name="action" value="<?= $suspended ? 'unsuspend' : 'suspend' ?>">
<div class="form-group" style="margin-bottom:0; min-width: 300px;">
<label class="form-label">السبب<?= $suspended ? '' : ' *' ?></label>
<input type="text" name="reason" class="form-input" maxlength="500" <?= $suspended ? '' : 'required' ?>>
</div>
<button type="submit" class="btn <?= $suspended ? 'btn-success' : 'btn-danger' ?>" onclick="return confirm('<?= $suspended ? 'إعادة تفعيل المنظمة؟ الأكواد الملغاة تبقى ملغاة.' : 'إيقاف المنظمة؟ تُلغى كل أكوادها ولا يمكن الانضمام إليها حتى رفع الإيقاف.' ?>')"><?= $suspended ? 'رفع الإيقاف' : 'إيقاف وإلغاء الأكواد' ?></button>
</form>
</div>
<div style="grid-column: span 2;">
<div class="text-xs text-muted mb-1">الوصف (English)</div>
<div class="text-sm"><?= View::e($org['description'] ?? '-') ?></div>
</div>
<div style="grid-column: span 2;">
<div class="text-xs text-muted mb-1">الوصف (عربي)</div>
<div class="text-sm"><?= View::e($org['description_ar'] ?? '-') ?></div>
</div>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
<!-- Members Tab -->
<div id="membersTab" class="tab-content hidden">
<?php if ($tab === 'members'): ?>
<div class="p-4">
<div class="flex justify-between items-center mb-4">
<h4 class="font-medium">أعضاء المنظمة (<?= count($members ?? []) ?>)</h4>
<button class="btn btn-primary btn-sm" onclick="showAddMemberModal()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
إضافة عضو
</button>
<div class="flex justify-between items-center mb-4 flex-wrap gap-3">
<div class="filter-pills">
<?php foreach (OrganizationsController::MEMBER_STATUS as $k => [$l]): ?>
<a href="/organizations/<?= $org['id'] ?>?tab=members&mstatus=<?= $k ?>" class="filter-pill <?= $mstatus === $k ? 'active' : '' ?>"><?= $l ?> (<?= (int)($memberCounts[$k] ?? 0) ?>)</a>
<?php endforeach; ?>
</div>
<?php if ($canMembers): ?>
<form method="POST" action="/organizations/<?= $org['id'] ?>/members/add" class="flex gap-2 items-center">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<input type="text" name="player" class="form-input" style="width: 180px;" placeholder="اسم المستخدم" dir="ltr" required>
<select name="role" class="form-select" style="width: auto;" aria-label="الدور">
<?php foreach (OrganizationsController::ROLES as $k => $l): ?><option value="<?= $k ?>" <?= $k === 'member' ? 'selected' : '' ?>><?= $l ?></option><?php endforeach; ?>
</select>
<button type="submit" class="btn btn-primary btn-sm">إضافة عضو</button>
</form>
<?php endif; ?>
</div>
</div>
<?php if (empty($members)): ?>
<p class="text-secondary text-center p-5">لا يوجد أعضاء</p>
<p class="text-secondary text-center p-5">لا يوجد أعضاء بهذه الحالة</p>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<th>العضو</th>
<th>الدور</th>
<th>تاريخ الانضمام</th>
<th style="width: 60px;">الإجراءات</th>
</tr>
</thead>
<thead><tr><th>العضو</th><th>الدور</th><th>الحالة</th><th>الانضمام</th><?php if ($canMembers): ?><th>الإجراءات</th><?php endif; ?></tr></thead>
<tbody>
<?php foreach ($members as $member): ?>
<?php foreach ($members as $m): ?>
<?php $p = $m['player']; [$sl, $sb] = OrganizationsController::MEMBER_STATUS[$m['status']] ?? [$m['status'], 'default']; $base = "/organizations/{$org['id']}/members/{$m['id']}/action"; ?>
<tr>
<td>
<div class="flex items-center gap-3">
<div class="avatar avatar-sm">
<?= mb_substr($member['display_name'] ?? $member['user_id'] ?? '?', 0, 1) ?>
</div>
<div class="avatar avatar-sm"><?php if (!empty($p['avatar_url'])): ?><img src="<?= View::e($p['avatar_url']) ?>" alt=""><?php else: ?><?= View::e(mb_substr($p['username'] ?? '?', 0, 1)) ?><?php endif; ?></div>
<div>
<div class="font-medium"><?= View::e($member['display_name'] ?? $member['username'] ?? $member['user_id']) ?></div>
<div class="font-medium"><?php if ($p): ?><a href="/players/<?= $p['id'] ?>" class="text-link"><?= View::e($p['display_name'] ?: $p['username']) ?></a><?php else: ?>-<?php endif; ?></div>
<div class="text-xs text-muted" dir="ltr">@<?= View::e($p['username'] ?? '') ?></div>
</div>
</div>
</td>
<td><span class="badge <?= $roleBadge[$m['role']] ?? 'badge-default' ?>"><?= View::e(OrganizationsController::ROLES[$m['role']] ?? $m['role']) ?></span></td>
<td>
<?php
$roleLabels = [
'owner' => 'مالك',
'admin' => 'مدير',
'moderator' => 'مشرف',
'member' => 'عضو',
];
$roleBadge = [
'owner' => 'badge-purple',
'admin' => 'badge-danger',
'moderator' => 'badge-warning',
'member' => 'badge-default',
];
$role = $member['role'] ?? 'member';
?>
<span class="badge <?= $roleBadge[$role] ?? 'badge-default' ?>"><?= $roleLabels[$role] ?? $role ?></span>
<span class="badge badge-<?= $sb ?> badge-sm"><?= $sl ?></span>
<?php if (!empty($m['is_muted']) && (empty($m['muted_until']) || strtotime($m['muted_until']) > time())): ?><span class="badge badge-warning badge-sm">مكتوم<?= !empty($m['muted_until']) ? ' حتى ' . Time::show($m['muted_until'], 'm/d H:i') : '' ?></span><?php endif; ?>
<?php if (!empty($m['ban_reason'])): ?><div class="text-xs text-muted"><?= View::e($m['ban_reason']) ?></div><?php endif; ?>
</td>
<td class="text-xs tabular-nums"><?= !empty($member['joined_at']) ? date('Y-m-d', strtotime($member['joined_at'])) : '-' ?></td>
<td class="text-xs tabular-nums"><?= Time::show($m['joined_at'] ?? null, 'Y/m/d') ?><div class="text-muted"><?= View::e($m['joined_via'] ?? '') ?></div></td>
<?php if ($canMembers): ?>
<td>
<button class="btn btn-icon btn-ghost btn-danger-hover" onclick="removeMember('<?= $member['id'] ?>', '<?= View::e($member['display_name'] ?? $member['user_id']) ?>')" title="إزالة العضو">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
<div class="flex gap-2 flex-wrap items-center">
<?php if ($m['status'] === 'active'): ?>
<form method="POST" action="<?= $base ?>" class="flex gap-1">
<input type="hidden" name="_csrf" value="<?= $csrf ?>"><input type="hidden" name="action" value="role">
<select name="role" class="form-select" style="width:auto; font-size:12px;" aria-label="تغيير الدور">
<?php foreach (OrganizationsController::ROLES as $k => $l): ?><option value="<?= $k ?>" <?= $m['role'] === $k ? 'selected' : '' ?>><?= $l ?></option><?php endforeach; ?>
</select>
<button type="submit" class="btn btn-xs btn-ghost">حفظ</button>
</form>
<?php if (!empty($m['is_muted'])): ?>
<form method="POST" action="<?= $base ?>" style="display:inline;"><input type="hidden" name="_csrf" value="<?= $csrf ?>"><input type="hidden" name="action" value="unmute"><button type="submit" class="btn btn-xs btn-ghost">إلغاء الكتم</button></form>
<?php else: ?>
<form method="POST" action="<?= $base ?>" class="flex gap-1"><input type="hidden" name="_csrf" value="<?= $csrf ?>"><input type="hidden" name="action" value="mute">
<select name="minutes" class="form-select" style="width:auto; font-size:12px;" aria-label="مدة الكتم"><option value="60">ساعة</option><option value="1440">يوم</option><option value="10080">أسبوع</option><option value="0">حتى الإلغاء</option></select>
<button type="submit" class="btn btn-xs btn-ghost">كتم</button></form>
<?php endif; ?>
<form method="POST" action="<?= $base ?>" class="flex gap-1"><input type="hidden" name="_csrf" value="<?= $csrf ?>"><input type="hidden" name="action" value="kick">
<input type="text" name="reason" class="form-input" style="width: 110px; padding: var(--space-1) var(--space-2); font-size: 12px;" placeholder="سبب (اختياري)">
<button type="submit" class="btn btn-xs btn-ghost text-danger" onclick="return confirm('إخراج العضو من المنظمة؟')">إخراج</button></form>
<?php endif; ?>
<?php if ($m['status'] === 'banned'): ?>
<form method="POST" action="<?= $base ?>" style="display:inline;"><input type="hidden" name="_csrf" value="<?= $csrf ?>"><input type="hidden" name="action" value="unban"><button type="submit" class="btn btn-xs btn-ghost">رفع الحظر</button></form>
<?php else: ?>
<form method="POST" action="<?= $base ?>" class="flex gap-1"><input type="hidden" name="_csrf" value="<?= $csrf ?>"><input type="hidden" name="action" value="ban">
<input type="text" name="reason" class="form-input" style="width: 110px; padding: var(--space-1) var(--space-2); font-size: 12px;" placeholder="سبب الحظر" required>
<button type="submit" class="btn btn-xs btn-danger" onclick="return confirm('حظر اللاعب من المنظمة؟ يُرفض أي طلب معلق له ولا تعمل أكواد المنظمة معه.')">حظر</button></form>
<?php endif; ?>
<?php if (in_array($m['status'], ['left', 'suspended'], true)): ?>
<form method="POST" action="<?= $base ?>" style="display:inline;"><input type="hidden" name="_csrf" value="<?= $csrf ?>"><input type="hidden" name="action" value="reactivate"><button type="submit" class="btn btn-xs btn-ghost">إعادة كعضو</button></form>
<?php endif; ?>
</div>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Tournaments Tab -->
<div id="tournamentsTab" class="tab-content hidden">
<?php if ($tab === 'tournaments'): ?>
<?php if (empty($tournaments)): ?>
<p class="text-secondary text-center p-5">لا توجد بطولات</p>
<?php else: ?>
<table class="data-table">
<thead>
<thead><tr><th>البطولة</th><th>اللعبة</th><th>الحالة</th><th>اللاعبون</th><th>البداية</th></tr></thead>
<tbody>
<?php foreach ($tournaments as $t): ?>
<tr>
<th>البطولة</th>
<th>اللعبة</th>
<th>الحالة</th>
<th>المشاركين</th>
<th>التاريخ</th>
<td><a href="/tournaments/<?= $t['id'] ?>" class="font-medium text-primary"><?= View::e(($t['name_ar'] ?? '') ?: $t['name']) ?></a></td>
<td><?= View::e($t['game_key'] ?? '-') ?></td>
<td><span class="badge badge-default"><?= View::e($t['status'] ?? '-') ?></span></td>
<td class="tabular-nums"><?= (int)($t['max_players'] ?? 0) ?></td>
<td class="text-xs tabular-nums"><?= Time::show($t['starts_at'] ?? null, 'Y/m/d H:i') ?></td>
</tr>
</thead>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php endif; ?>
<?php if ($tab === 'reports'): ?>
<?php if (empty($reports)): ?>
<p class="text-secondary text-center p-5">لا توجد بلاغات ضد هذه المنظمة</p>
<?php else: ?>
<table class="data-table">
<thead><tr><th>المبلّغ</th><th>السبب</th><th>التفاصيل</th><th>الحالة</th><th>الوقت</th><?php if ($canPlatform): ?><th>المراجعة</th><?php endif; ?></tr></thead>
<tbody>
<?php foreach ($tournaments as $tournament): ?>
<?php foreach ($reports as $r): ?>
<?php $rp = $reporters[$r['reporter_id'] ?? ''] ?? null; [$rl, $rb] = OrganizationsController::REPORT_STATUS[$r['status']] ?? [$r['status'], 'default']; ?>
<tr>
<td><?= $rp ? View::e($rp['display_name'] ?: $rp['username']) : '-' ?></td>
<td><?= View::e(OrganizationsController::REPORT_REASONS[$r['reason']] ?? $r['reason']) ?></td>
<td class="text-sm"><?= View::e($r['details'] ?? '') ?><?php if (!empty($r['resolution'])): ?><div class="text-xs text-muted">القرار: <?= View::e($r['resolution']) ?></div><?php endif; ?></td>
<td><span class="badge badge-<?= $rb ?> badge-sm"><?= $rl ?></span></td>
<td class="text-xs tabular-nums"><?= Time::show($r['created_at'] ?? null) ?></td>
<?php if ($canPlatform): ?>
<td>
<a href="/tournaments/<?= $tournament['id'] ?>" class="font-medium text-primary">
<?= View::e($tournament['name_ar'] ?? $tournament['name'] ?? '-') ?>
</a>
</td>
<td><?= View::e($tournament['game_key'] ?? '-') ?></td>
<td>
<?php
$statusLabels = [
'draft' => 'مسودة',
'open' => 'مفتوحة',
'in_progress' => 'جارية',
'completed' => 'مكتملة',
'cancelled' => 'ملغية',
];
$statusBadge = [
'draft' => 'badge-default',
'open' => 'badge-info',
'in_progress' => 'badge-warning',
'completed' => 'badge-success',
'cancelled' => 'badge-danger',
];
$tStatus = $tournament['status'] ?? 'draft';
?>
<span class="badge <?= $statusBadge[$tStatus] ?? 'badge-default' ?>"><?= $statusLabels[$tStatus] ?? $tStatus ?></span>
<form method="POST" action="/organizations/reports/<?= $r['id'] ?>/update" class="flex gap-1">
<input type="hidden" name="_csrf" value="<?= $csrf ?>"><input type="hidden" name="back" value="/organizations/<?= $org['id'] ?>?tab=reports">
<select name="status" class="form-select" style="width:auto; font-size:12px;" aria-label="الحالة"><?php foreach (OrganizationsController::REPORT_STATUS as $k => [$l]): ?><option value="<?= $k ?>" <?= $r['status'] === $k ? 'selected' : '' ?>><?= $l ?></option><?php endforeach; ?></select>
<input type="text" name="resolution" class="form-input" style="width: 140px; padding: var(--space-1) var(--space-2); font-size: 12px;" placeholder="القرار">
<button type="submit" class="btn btn-xs btn-ghost">حفظ</button>
</form>
</td>
<td class="tabular-nums"><?= $tournament['max_players'] ?? 0 ?></td>
<td class="text-xs tabular-nums"><?= !empty($tournament['starts_at']) ? date('Y-m-d', strtotime($tournament['starts_at'])) : '-' ?></td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Verification Tab -->
<div id="verificationTab" class="tab-content hidden">
<div class="p-5">
<div class="flex flex-col items-center text-center">
<?php if ($org['is_verified'] ?? false): ?>
<div class="verification-badge-lg mb-4">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-success)" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<h3 class="text-lg font-semibold text-success mb-2">منظمة موثقة</h3>
<div class="flex flex-col gap-2 text-sm text-secondary">
<?php if (!empty($org['verified_at'])): ?>
<p>تاريخ التوثيق: <?= date('Y-m-d H:i', strtotime($org['verified_at'])) ?></p>
<?php endif; ?>
<?php if (!empty($org['verified_by'])): ?>
<p>بواسطة: <?= View::e($org['verified_by']) ?></p>
<?php endif; ?>
</div>
<?php if ($tab === 'activity'): ?>
<?php if (empty($activity)): ?>
<p class="text-secondary text-center p-5">لا يوجد نشاط مسجل</p>
<?php else: ?>
<div class="verification-badge-lg mb-4">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-warning)" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<h3 class="text-lg font-semibold text-warning mb-2">غير موثقة</h3>
<p class="text-sm text-secondary mb-4">هذه المنظمة لم يتم توثيقها بعد</p>
<form method="POST" action="/organizations/<?= $org['id'] ?>/verify">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-success" onclick="return confirm('هل أنت متأكد من توثيق هذه المنظمة؟')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
توثيق المنظمة
</button>
</form>
<table class="data-table">
<thead><tr><th>الوقت</th><th>الحدث</th><th>التفاصيل</th></tr></thead>
<tbody>
<?php foreach ($activity as $a): ?>
<tr>
<td class="text-xs tabular-nums"><?= Time::show($a['created_at'] ?? null) ?></td>
<td class="text-sm"><?= View::e(OrganizationsController::ACTIVITY[$a['action']] ?? $a['action']) ?></td>
<td class="text-xs text-muted" dir="ltr" style="text-align: start;"><?= View::e(mb_substr(json_encode($a['details'] ?? [], JSON_UNESCAPED_UNICODE), 0, 200)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
</div>
<?php endif; ?>
</div>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment