Commit be45caf1 authored by Mahmoud Aglan's avatar Mahmoud Aglan

stuff

parent e80202ff
<?php
declare(strict_types=1);
namespace App\Modules\AcademyContracts\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
class ReportController extends Controller
{
public function guaranteeReport(Request $request): Response
{
$db = App::getInstance()->db();
$filterAcademy = trim((string) $request->get('academy_id', ''));
$filterFrom = trim((string) $request->get('from_month', ''));
$filterTo = trim((string) $request->get('to_month', ''));
if ($filterFrom === '') {
$filterFrom = date('Y-01');
}
if ($filterTo === '') {
$filterTo = date('Y-m');
}
$where = ["ac.status = 'active'", 'ac.is_archived = 0'];
$params = [];
if ($filterAcademy !== '') {
$where[] = 'ac.academy_id = ?';
$params[] = (int) $filterAcademy;
}
$whereSql = implode(' AND ', $where);
$contracts = $db->select(
"SELECT ac.id, ac.contract_number, ac.academy_id, ac.minimum_revenue_guarantee,
ac.club_commission_pct, ac.academy_share_pct,
a.name_ar as academy_name
FROM academy_contracts ac
INNER JOIN academies a ON a.id = ac.academy_id
WHERE {$whereSql}
ORDER BY a.name_ar ASC",
$params
);
$reportData = [];
foreach ($contracts as $contract) {
$settlements = $db->select(
"SELECT period_month, total_revenue, minimum_guarantee, revenue_surplus,
revenue_shortfall, direction, net_amount, status
FROM academy_settlements
WHERE contract_id = ? AND period_month >= ? AND period_month <= ?
ORDER BY period_month ASC",
[(int) $contract['id'], $filterFrom, $filterTo]
);
$totalRevenue = 0.0;
$totalGuarantee = 0.0;
$totalDeficit = 0.0;
$totalSurplus = 0.0;
foreach ($settlements as $s) {
$totalRevenue += (float) $s['total_revenue'];
$totalGuarantee += (float) $s['minimum_guarantee'];
$totalDeficit += (float) $s['revenue_shortfall'];
$totalSurplus += (float) $s['revenue_surplus'];
}
$reportData[] = [
'contract' => $contract,
'settlements' => $settlements,
'total_revenue' => $totalRevenue,
'total_guarantee' => $totalGuarantee,
'total_deficit' => $totalDeficit,
'total_surplus' => $totalSurplus,
'net_position' => $totalRevenue - $totalGuarantee,
];
}
$academies = $db->select(
"SELECT id, name_ar FROM academies WHERE is_active = 1 AND is_archived = 0 ORDER BY name_ar ASC"
);
return $this->view('AcademyContracts.Views.guarantee_report', [
'reportData' => $reportData,
'academies' => $academies,
'filterAcademy' => $filterAcademy,
'filterFrom' => $filterFrom,
'filterTo' => $filterTo,
]);
}
}
......@@ -11,6 +11,7 @@ return [
['POST', '/academy-contracts/{id:\d+}/activate', 'AcademyContracts\Controllers\AcademyContractController@activate', ['auth', 'csrf'], 'academy_contract.manage'],
['POST', '/academy-contracts/{id:\d+}/terminate', 'AcademyContracts\Controllers\AcademyContractController@terminate', ['auth', 'csrf'], 'academy_contract.manage'],
['GET', '/academy-settlements/report', 'AcademyContracts\Controllers\ReportController@guaranteeReport', ['auth'], 'academy_contract.settlement'],
['GET', '/academy-settlements', 'AcademyContracts\Controllers\SettlementController@index', ['auth'], 'academy_contract.settlement'],
['GET', '/academy-settlements/{id:\d+}', 'AcademyContracts\Controllers\SettlementController@show', ['auth'], 'academy_contract.settlement'],
['POST', '/academy-settlements/generate', 'AcademyContracts\Controllers\SettlementController@generate', ['auth', 'csrf'], 'academy_contract.settlement'],
......
......@@ -36,7 +36,24 @@ final class SettlementService
[$academyId, $month]
);
$totalRevenue = (float) ($subscriptionRevenue['total'] ?? 0);
$enrollmentRevenue = (float) ($subscriptionRevenue['total'] ?? 0);
// Also include revenue from SA groups led by academy-linked coaches
$coachGroupRevenue = $db->selectOne(
"SELECT COALESCE(SUM(pr.amount), 0) as total
FROM payment_requests pr
INNER JOIN sa_group_players sgp ON sgp.id = pr.related_entity_id AND pr.related_entity_type = 'sa_group_players'
INNER JOIN sa_groups sg ON sg.id = sgp.group_id
INNER JOIN sa_coaches sc ON sc.id = sg.coach_id
WHERE sc.academy_id = ?
AND sc.coach_type = 'academy'
AND pr.status IN ('paid', 'completed')
AND DATE_FORMAT(pr.paid_at, '%Y-%m') = ?",
[$academyId, $month]
);
$coachRevenue = (float) ($coachGroupRevenue['total'] ?? 0);
$totalRevenue = $enrollmentRevenue + $coachRevenue;
$minimumGuarantee = (float) $contract->minimum_revenue_guarantee;
$clubCommissionPct = (float) $contract->club_commission_pct;
$academySharePct = (float) $contract->academy_share_pct;
......@@ -95,9 +112,9 @@ final class SettlementService
'academy_id' => $academyId,
'contract_id' => $contractId,
'period_month' => $month,
'subscription_revenue' => $totalRevenue,
'subscription_revenue' => $enrollmentRevenue,
'registration_revenue' => 0.00,
'other_revenue' => 0.00,
'other_revenue' => $coachRevenue,
'total_revenue' => $totalRevenue,
'enrolled_count' => (int) ($playerCounts['enrolled_count'] ?? 0),
'active_players_count' => (int) ($playerCounts['active_count'] ?? 0),
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تقرير الحد الأدنى المضمون<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/academy-settlements" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> التسويات</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;">
<form method="GET" style="display:flex;align-items:end;gap:15px;flex-wrap:wrap;">
<div class="form-group" style="margin:0;min-width:180px;">
<label class="form-label" style="font-size:12px;">الأكاديمية</label>
<select name="academy_id" class="form-select">
<option value="">— كل الأكاديميات —</option>
<?php foreach ($academies as $acad): ?>
<option value="<?= (int) $acad['id'] ?>" <?= $filterAcademy == $acad['id'] ? 'selected' : '' ?>><?= e($acad['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="margin:0;min-width:150px;">
<label class="form-label" style="font-size:12px;">من شهر</label>
<input type="month" name="from_month" value="<?= e($filterFrom) ?>" class="form-input" style="direction:ltr;text-align:left;">
</div>
<div class="form-group" style="margin:0;min-width:150px;">
<label class="form-label" style="font-size:12px;">إلى شهر</label>
<input type="month" name="to_month" value="<?= e($filterTo) ?>" class="form-input" style="direction:ltr;text-align:left;">
</div>
<button type="submit" class="btn btn-primary" style="padding:9px 20px;"><i data-lucide="filter" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> عرض</button>
</form>
</div>
</div>
<?php if (empty($reportData)): ?>
<div class="card" style="padding:40px;text-align:center;color:#9CA3AF;">
<i data-lucide="file-x" style="width:40px;height:40px;margin-bottom:10px;opacity:0.5;"></i>
<p>لا توجد عقود نشطة بحد أدنى مضمون.</p>
</div>
<?php else: ?>
<!-- Summary Cards -->
<?php
$grandRevenue = array_sum(array_column($reportData, 'total_revenue'));
$grandGuarantee = array_sum(array_column($reportData, 'total_guarantee'));
$grandDeficit = array_sum(array_column($reportData, 'total_deficit'));
$grandSurplus = array_sum(array_column($reportData, 'total_surplus'));
?>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:15px;margin-bottom:20px;">
<div class="card" style="padding:16px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">إجمالي الإيرادات</div>
<div style="font-size:20px;font-weight:800;color:#1A1A2E;margin-top:4px;"><?= money($grandRevenue) ?></div>
</div>
<div class="card" style="padding:16px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">إجمالي الضمان المطلوب</div>
<div style="font-size:20px;font-weight:800;color:#7C3AED;margin-top:4px;"><?= money($grandGuarantee) ?></div>
</div>
<div class="card" style="padding:16px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">إجمالي الفائض</div>
<div style="font-size:20px;font-weight:800;color:#059669;margin-top:4px;"><?= money($grandSurplus) ?></div>
</div>
<div class="card" style="padding:16px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">إجمالي العجز</div>
<div style="font-size:20px;font-weight:800;color:#DC2626;margin-top:4px;"><?= money($grandDeficit) ?></div>
</div>
</div>
<!-- Report Table -->
<?php foreach ($reportData as $row): ?>
<div class="card" style="margin-bottom:16px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<div>
<strong style="font-size:15px;color:#1A1A2E;"><?= e($row['contract']['academy_name']) ?></strong>
<span style="margin-right:10px;font-size:12px;color:#6B7280;">عقد <?= e($row['contract']['contract_number'] ?? '') ?></span>
</div>
<div style="display:flex;gap:12px;">
<span style="font-size:12px;color:#6B7280;">الضمان الشهري: <strong style="color:#7C3AED;"><?= money((float) $row['contract']['minimum_revenue_guarantee']) ?></strong></span>
<?php if ($row['net_position'] >= 0): ?>
<span style="padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;background:#ECFDF5;color:#059669;">فائض <?= money($row['total_surplus']) ?></span>
<?php else: ?>
<span style="padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;background:#FEF2F2;color:#DC2626;">عجز <?= money($row['total_deficit']) ?></span>
<?php endif; ?>
</div>
</div>
<?php if (!empty($row['settlements'])): ?>
<div style="padding:0;">
<table style="width:100%;border-collapse:collapse;font-size:13px;">
<thead>
<tr style="background:#F9FAFB;">
<th style="padding:10px 15px;text-align:right;font-weight:600;color:#6B7280;">الشهر</th>
<th style="padding:10px 15px;text-align:right;font-weight:600;color:#6B7280;">الإيرادات</th>
<th style="padding:10px 15px;text-align:right;font-weight:600;color:#6B7280;">الحد الأدنى</th>
<th style="padding:10px 15px;text-align:right;font-weight:600;color:#6B7280;">الفائض</th>
<th style="padding:10px 15px;text-align:right;font-weight:600;color:#6B7280;">العجز</th>
<th style="padding:10px 15px;text-align:right;font-weight:600;color:#6B7280;">الحالة</th>
</tr>
</thead>
<tbody>
<?php foreach ($row['settlements'] as $s): ?>
<tr style="border-top:1px solid #F3F4F6;">
<td style="padding:10px 15px;direction:ltr;text-align:right;"><?= e($s['period_month']) ?></td>
<td style="padding:10px 15px;"><?= money((float) $s['total_revenue']) ?></td>
<td style="padding:10px 15px;"><?= money((float) $s['minimum_guarantee']) ?></td>
<td style="padding:10px 15px;color:#059669;font-weight:600;"><?= (float) $s['revenue_surplus'] > 0 ? money((float) $s['revenue_surplus']) : '—' ?></td>
<td style="padding:10px 15px;color:#DC2626;font-weight:600;"><?= (float) $s['revenue_shortfall'] > 0 ? money((float) $s['revenue_shortfall']) : '—' ?></td>
<td style="padding:10px 15px;">
<?php
$statusColors = ['pending_approval' => '#F59E0B', 'approved' => '#059669', 'paid' => '#2563EB', 'draft' => '#6B7280', 'disputed' => '#DC2626'];
$statusLabels = ['pending_approval' => 'بانتظار الاعتماد', 'approved' => 'معتمدة', 'paid' => 'مدفوعة', 'draft' => 'مسودة', 'disputed' => 'متنازع عليها'];
$sc = $statusColors[$s['status']] ?? '#6B7280';
$sl = $statusLabels[$s['status']] ?? $s['status'];
?>
<span style="padding:3px 8px;border-radius:4px;font-size:11px;font-weight:600;background:<?= $sc ?>15;color:<?= $sc ?>;"><?= e($sl) ?></span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div style="padding:20px;text-align:center;color:#9CA3AF;font-size:13px;">لا توجد تسويات في هذه الفترة</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
......@@ -136,6 +136,119 @@ class CarnetController extends Controller
]);
}
public function issueDependent(Request $request, string $memberId, string $type, string $dependentId): Response
{
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]);
if (!$member) {
return $this->redirect('/members')->withError('العضو غير موجود');
}
$allowedTypes = ['spouses', 'children', 'temporary_members'];
if (!in_array($type, $allowedTypes, true)) {
return $this->redirect("/members/{$memberId}")->withError('نوع التابع غير صالح');
}
$dependent = $db->selectOne("SELECT * FROM {$type} WHERE id = ? AND member_id = ? AND is_archived = 0", [(int) $dependentId, (int) $memberId]);
if (!$dependent) {
return $this->redirect("/members/{$memberId}")->withError('التابع غير موجود');
}
$blockReasons = CarnetPrintService::checkDependentEligibility((int) $memberId, $type, (int) $dependentId);
if (!empty($blockReasons)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($r) => ['type' => 'error', 'message' => $r], $blockReasons));
return $this->redirect("/members/{$memberId}");
}
$employee = App::getInstance()->currentEmployee();
$existingActive = Carnet::getActiveForDependent($type, (int) $dependentId);
if ($existingActive) {
$db->update('carnets', [
'is_active' => 0,
'deactivated_at' => date('Y-m-d H:i:s'),
'deactivated_reason' => 'إصدار كارنيه جديد',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $existingActive['id']]);
}
$carnetNumber = Carnet::generateNumber();
$qrData = QRCodeGenerator::encode(($member['membership_number'] ?? (string) $member['id']) . '-' . substr($type, 0, 1) . $dependentId);
$carnet = Carnet::create([
'member_id' => (int) $memberId,
'dependent_type' => $type,
'dependent_id' => (int) $dependentId,
'card_variant' => 'dependent',
'carnet_number' => $carnetNumber,
'carnet_type' => 'regular',
'qr_code_data' => $qrData,
'is_active' => 1,
'issued_by' => $employee ? (int) $employee->id : null,
'replacement_count' => $existingActive ? (int) ($existingActive['replacement_count'] ?? 0) + 1 : 0,
'previous_carnet_id' => $existingActive ? (int) $existingActive['id'] : null,
]);
EventBus::dispatch('carnet.issued', [
'carnet_id' => (int) $carnet->id,
'member_id' => (int) $memberId,
'dependent_type' => $type,
'dependent_id' => (int) $dependentId,
'carnet_number' => $carnetNumber,
]);
return $this->redirect("/carnets/{$carnet->id}/print-dependent")->withSuccess('تم إصدار كارنيه التابع رقم: ' . $carnetNumber);
}
public function printDependent(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$carnet = $db->selectOne("SELECT * FROM carnets WHERE id = ? AND dependent_type IS NOT NULL", [(int) $id]);
if (!$carnet) {
return $this->redirect('/carnets')->withError('كارنيه التابع غير موجود');
}
$member = $db->selectOne(
"SELECT m.full_name_ar, m.full_name_en, m.membership_number, m.photo_path, b.name_ar as branch_name
FROM members m LEFT JOIN branches b ON b.id = m.branch_id WHERE m.id = ?",
[(int) $carnet['member_id']]
);
$dependent = $db->selectOne(
"SELECT * FROM {$carnet['dependent_type']} WHERE id = ?",
[(int) $carnet['dependent_id']]
);
if (!$member || !$dependent) {
return $this->redirect('/carnets')->withError('بيانات غير مكتملة');
}
$employee = App::getInstance()->currentEmployee();
$db->update('carnets', [
'print_count' => (int) $carnet['print_count'] + 1,
'last_printed_at' => date('Y-m-d H:i:s'),
'last_printed_by' => $employee ? (int) $employee->id : null,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
$typeLabels = [
'spouses' => 'زوج/زوجة',
'children' => 'ابن/ابنة',
'temporary_members' => 'عضو مؤقت',
];
$qrSvg = QRCodeGenerator::renderSvg($carnet['qr_code_data'] ?? '');
return $this->view('Carnets.Views.print-dependent', [
'carnet' => $carnet,
'member' => $member,
'dependent' => $dependent,
'typeLabel' => $typeLabels[$carnet['dependent_type']] ?? 'تابع',
'qrSvg' => $qrSvg,
]);
}
public function replace(Request $request, string $memberId): Response
{
$db = App::getInstance()->db();
......
......@@ -16,7 +16,8 @@ class Carnet extends Model
protected static bool $dispatchEvents = true;
protected static array $fillable = [
'member_id', 'carnet_number', 'carnet_type', 'qr_code_data',
'member_id', 'dependent_type', 'dependent_id', 'card_variant',
'carnet_number', 'carnet_type', 'qr_code_data',
'is_active', 'issued_by', 'deactivated_at', 'deactivated_reason',
'replacement_count', 'previous_carnet_id',
'print_count', 'last_printed_at', 'last_printed_by',
......@@ -26,11 +27,20 @@ class Carnet extends Model
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT * FROM carnets WHERE member_id = ? AND is_active = 1 ORDER BY issued_at DESC LIMIT 1",
"SELECT * FROM carnets WHERE member_id = ? AND is_active = 1 AND dependent_type IS NULL ORDER BY issued_at DESC LIMIT 1",
[$memberId]
);
}
public static function getActiveForDependent(string $type, int $id): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT * FROM carnets WHERE dependent_type = ? AND dependent_id = ? AND is_active = 1 ORDER BY issued_at DESC LIMIT 1",
[$type, $id]
);
}
public static function getAllForMember(int $memberId): array
{
$db = App::getInstance()->db();
......
......@@ -8,6 +8,8 @@ return [
['GET', '/carnets/{id}/print', 'Carnets\Controllers\CarnetController@print', ['auth'], 'carnet.print'],
['POST', '/carnets/{id}/deactivate', 'Carnets\Controllers\CarnetController@deactivate', ['auth', 'csrf'], 'carnet.deactivate'],
['GET', '/carnets/replace/{memberId}', 'Carnets\Controllers\CarnetController@replace', ['auth'], 'carnet.issue'],
['POST', '/carnets/issue/{memberId}/{type}/{dependentId}', 'Carnets\Controllers\CarnetController@issueDependent', ['auth', 'csrf'], 'carnet.issue'],
['GET', '/carnets/{id}/print-dependent', 'Carnets\Controllers\CarnetController@printDependent', ['auth'], 'carnet.print'],
// Guest Entries (Invitation Usage)
['GET', '/carnets/guest-entries', 'Carnets\Controllers\GuestEntryController@index', ['auth'], 'carnet.view_guests'],
......
......@@ -78,6 +78,30 @@ final class CarnetPrintService
return $reasons;
}
public static function checkDependentEligibility(int $memberId, string $dependentType, int $dependentId): array
{
$reasons = self::checkEligibility($memberId);
$db = App::getInstance()->db();
$dependent = $db->selectOne("SELECT * FROM {$dependentType} WHERE id = ? AND member_id = ? AND is_archived = 0", [$dependentId, $memberId]);
if (!$dependent) {
$reasons[] = 'التابع غير موجود أو تمت إزالته';
return $reasons;
}
if (empty($dependent['photo_path'])) {
$reasons[] = 'يجب رفع صورة شخصية للتابع قبل إصدار الكارنيه';
}
$status = $dependent['status'] ?? '';
if (!in_array($status, ['active', 'pending_payment'], true)) {
$reasons[] = 'حالة التابع غير فعالة: ' . $status;
}
return $reasons;
}
private static function getCurrentFinancialYear(): string
{
$month = (int) date('n');
......
<?php
use App\Modules\Settings\Services\BrandingService;
$design = BrandingService::carnetDesign();
$clubNameAr = BrandingService::clubNameAr();
$clubNameEn = BrandingService::clubNameEn();
$logoUrl = BrandingService::logo();
$stripColor = $design['back_strip_color'];
$bgColor = $design['front_bg_color'];
$textColor = $design['front_text_color'];
$depPhotoPath = $dependent['photo_path'] ?? '';
$parentPhotoPath = $member['photo_path'] ?? '';
$membershipNumber = $member['membership_number'] ?? '—';
$depName = $dependent['full_name_ar'] ?? '—';
$depNameEn = $dependent['full_name_en'] ?? '';
$branchName = $member['branch_name'] ?? '';
?>
<?php $__template->layout('Layout.print'); ?>
<?php $__template->section('title'); ?>كارنيه تابع — <?= e($depName) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<style>
@media print { body { margin: 0; } .print-header { display: none; } }
.carnet-container { width: 340px; margin: 20px auto; font-family: 'Cairo', Arial, sans-serif; }
.carnet-front {
width: 340px; height: 215px; background: <?= e($bgColor) ?>; border-radius: 12px;
color: <?= e($textColor) ?>; position: relative; overflow: hidden; margin-bottom: 15px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; direction: rtl;
}
.carnet-front .photo-side {
width: 120px; height: 215px; flex-shrink: 0; position: relative; overflow: hidden;
border-radius: 12px 0 0 12px;
}
.carnet-front .photo-side img { width: 100%; height: 100%; object-fit: cover; }
.carnet-front .info-side {
flex: 1; padding: 12px 15px; display: flex; flex-direction: column; justify-content: space-between;
}
.carnet-front .club-header { text-align: center; }
.carnet-front .club-header img { max-height: 22px; }
.carnet-front .club-name { font-size: 11px; font-weight: 700; margin-top: 3px; }
.carnet-front .dep-name { font-size: 14px; font-weight: 700; margin-bottom: 2px; }
.carnet-front .dep-name-en { font-size: 10px; opacity: 0.8; margin-bottom: 5px; }
.carnet-front .dep-type { font-size: 11px; opacity: 0.9; margin-bottom: 6px; }
.carnet-front .parent-ref {
display: flex; align-items: center; gap: 8px; padding: 6px 8px;
background: rgba(255,255,255,0.15); border-radius: 6px; margin-top: auto;
}
.carnet-front .parent-ref .parent-photo {
width: 36px; height: 36px; border-radius: 50%; overflow: hidden; border: 2px solid rgba(255,255,255,0.5); flex-shrink: 0;
}
.carnet-front .parent-ref .parent-photo img { width: 100%; height: 100%; object-fit: cover; }
.carnet-front .parent-ref .parent-info { font-size: 10px; line-height: 1.4; }
.carnet-front .parent-ref .parent-number { font-weight: 700; font-size: 12px; direction: ltr; }
.carnet-front .carnet-num { font-size: 9px; opacity: 0.6; text-align: left; margin-top: 4px; }
.carnet-front .qr-area {
position: absolute; bottom: 8px; left: 8px; width: 50px; height: 50px;
background: #fff; border-radius: 5px; padding: 3px;
}
.carnet-front .qr-area svg { width: 44px; height: 44px; }
.carnet-back {
width: 340px; height: 215px; background: #fff; border-radius: 12px;
border: 2px solid <?= e($stripColor) ?>; padding: 18px 20px; position: relative;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.carnet-back .back-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; border-bottom: 2px solid <?= e($stripColor) ?>; padding-bottom: 8px; }
.carnet-back .back-header img { max-height: 22px; }
.carnet-back .back-header .logo-text { font-size: 11px; font-weight: 700; color: <?= e($stripColor) ?>; }
.carnet-back .branch-strip { position: absolute; left: 0; top: 0; bottom: 0; width: 28px; background: <?= e($stripColor) ?>; border-radius: 12px 0 0 12px; writing-mode: vertical-rl; text-orientation: mixed; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 10px; font-weight: 600; }
.carnet-back .dep-info { margin-right: 32px; font-size: 12px; color: #1A1A2E; }
.carnet-back .dep-info div { margin-bottom: 4px; }
.carnet-back .dep-info strong { color: <?= e($stripColor) ?>; }
</style>
<div class="carnet-container">
<!-- Front -->
<div class="carnet-front">
<div class="photo-side">
<?php if ($depPhotoPath): ?>
<img src="/<?= e($depPhotoPath) ?>" alt="صورة التابع">
<?php else: ?>
<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.15);color:rgba(255,255,255,0.6);font-size:11px;">لا توجد صورة</div>
<?php endif; ?>
</div>
<div class="info-side">
<div class="club-header">
<?php if ($logoUrl): ?>
<img src="<?= e($logoUrl) ?>" alt="Logo">
<?php endif; ?>
<div class="club-name"><?= e($clubNameAr) ?></div>
</div>
<div>
<div class="dep-name"><?= e($depName) ?></div>
<?php if ($depNameEn): ?>
<div class="dep-name-en"><?= e($depNameEn) ?></div>
<?php endif; ?>
<div class="dep-type"><?= e($typeLabel) ?></div>
</div>
<div class="parent-ref">
<div class="parent-photo">
<?php if ($parentPhotoPath): ?>
<img src="/<?= e($parentPhotoPath) ?>" alt="العضو">
<?php endif; ?>
</div>
<div class="parent-info">
تابع لعضوية رقم<br>
<span class="parent-number"><?= e($membershipNumber) ?></span>
</div>
</div>
<div class="carnet-num"><?= e($carnet['carnet_number']) ?></div>
</div>
<div class="qr-area"><?= $qrSvg ?></div>
</div>
<!-- Back -->
<div class="carnet-back">
<?php if ($branchName): ?>
<div class="branch-strip"><?= e($branchName) ?></div>
<?php endif; ?>
<div class="back-header">
<?php if ($logoUrl): ?>
<img src="<?= e($logoUrl) ?>" alt="Logo">
<?php else: ?>
<div class="logo-text"><?= e($clubNameEn) ?><br><?= e($clubNameAr) ?></div>
<?php endif; ?>
</div>
<div class="dep-info">
<div><strong>اسم التابع:</strong> <?= e($depName) ?></div>
<div><strong>نوع القرابة:</strong> <?= e($typeLabel) ?></div>
<div><strong>اسم العضو:</strong> <?= e($member['full_name_ar'] ?? '—') ?></div>
<div><strong>رقم العضوية:</strong> <?= e($membershipNumber) ?></div>
<div><strong>الفرع:</strong> <?= e($branchName) ?></div>
<div><strong>تاريخ الإصدار:</strong> <?= e(isset($carnet['issued_at']) ? substr($carnet['issued_at'], 0, 10) : date('Y-m-d')) ?></div>
</div>
</div>
</div>
<?php $__template->endSection(); ?>
......@@ -21,11 +21,6 @@ $showInstr = $design['back_show_instructions'];
$instrText = $design['back_instructions_text'];
$backFields = $design['back_fields'] ?? [];
// QR position CSS
$qrStyle = 'position:absolute;';
if (str_contains($qrPos, 'bottom')) $qrStyle .= 'bottom:15px;'; else $qrStyle .= 'top:15px;';
if (str_contains($qrPos, 'left')) $qrStyle .= 'left:15px;'; else $qrStyle .= 'right:15px;';
// Logo position CSS
$logoAlign = 'text-align:center;';
if ($logoPos === 'top-right') $logoAlign = 'text-align:right;';
......@@ -50,6 +45,8 @@ $fieldValues = [
'national_id' => $carnet['national_id'] ?? '—',
'phone' => $carnet['phone'] ?? '—',
];
$photoPath = $carnet['photo_path'] ?? '';
?>
<?php $__template->layout('Layout.print'); ?>
<?php $__template->section('title'); ?>كارنيه العضوية — <?= e($carnet['membership_number'] ?? $carnet['carnet_number']) ?><?php $__template->endSection(); ?>
......@@ -59,25 +56,43 @@ $fieldValues = [
.carnet-container { width: 340px; margin: 20px auto; font-family: 'Cairo', Arial, sans-serif; }
.carnet-front {
width: 340px; height: 215px; background: <?= e($bgColor) ?>; border-radius: 12px;
color: <?= e($textColor) ?>; padding: 20px; position: relative; overflow: hidden; margin-bottom: 15px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
color: <?= e($textColor) ?>; position: relative; overflow: hidden; margin-bottom: 15px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; direction: rtl;
}
.carnet-front .photo-side {
width: 120px; height: 215px; flex-shrink: 0; position: relative; overflow: hidden;
border-radius: 12px 0 0 12px;
}
.carnet-front .photo-side img {
width: 100%; height: 100%; object-fit: cover;
}
.carnet-front .photo-side .no-photo {
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.6); font-size: 11px;
}
.carnet-front .info-side {
flex: 1; padding: 15px 18px; display: flex; flex-direction: column; justify-content: space-between;
}
.carnet-front .club-header { <?= $logoAlign ?> }
.carnet-front .club-header .front-logo img { max-height: 26px; }
.carnet-front .club-name { font-size: 13px; font-weight: 700; margin-top: 4px; }
.carnet-front .club-subtitle { font-size: 10px; opacity: 0.8; }
.carnet-front .member-details { flex: 1; display: flex; flex-direction: column; justify-content: center; }
.carnet-front .member-name { font-size: 14px; font-weight: 700; margin-bottom: 3px; }
.carnet-front .member-name-en { font-size: 10px; opacity: 0.8; margin-bottom: 6px; }
.carnet-front .member-number { font-size: 18px; font-weight: 700; letter-spacing: 2px; direction: ltr; text-align: right; }
.carnet-front .member-type { font-size: 10px; margin-top: 3px; opacity: 0.8; }
.carnet-front .carnet-num { font-size: 9px; opacity: 0.6; text-align: left; }
.carnet-front .qr-area {
position: absolute; bottom: 10px; left: 10px; width: 55px; height: 55px;
background: #fff; border-radius: 5px; padding: 4px;
}
.carnet-front .qr-area svg { width: 47px; height: 47px; }
.carnet-back {
width: 340px; height: 215px; background: #fff; border-radius: 12px;
border: 2px solid <?= e($stripColor) ?>; padding: 20px; position: relative;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.carnet-front .club-name { <?= $logoAlign ?> font-size: 16px; font-weight: 700; margin-bottom: 4px; }
.carnet-front .club-subtitle { <?= $logoAlign ?> font-size: 11px; opacity: 0.8; margin-bottom: 15px; }
.carnet-front .front-logo { <?= $logoAlign ?> margin-bottom: 8px; }
.carnet-front .front-logo img { max-height: 30px; }
.carnet-front .member-name { font-size: 14px; font-weight: 700; margin-bottom: 5px; }
.carnet-front .member-name-en { font-size: 11px; opacity: 0.8; margin-bottom: 8px; }
.carnet-front .member-number { font-size: 20px; font-weight: 700; letter-spacing: 2px; direction: ltr; text-align: right; }
.carnet-front .member-type { font-size: 11px; margin-top: 5px; opacity: 0.8; }
.carnet-front .qr-area { <?= $qrStyle ?> width: 70px; height: 70px; background: #fff; border-radius: 6px; padding: 5px; }
.carnet-front .qr-area svg { width: 60px; height: 60px; }
.carnet-front .carnet-num { position: absolute; bottom: 15px; right: 15px; font-size: 10px; opacity: 0.7; }
.carnet-back .back-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 2px solid <?= e($stripColor) ?>; padding-bottom: 10px; }
.carnet-back .back-header .logo-text { font-size: 12px; font-weight: 700; color: <?= e($stripColor) ?>; }
.carnet-back .back-header .back-logo img { max-height: 25px; }
......@@ -91,13 +106,24 @@ $fieldValues = [
<div class="carnet-container">
<!-- Front -->
<div class="carnet-front">
<div class="photo-side">
<?php if ($photoPath): ?>
<img src="/<?= e($photoPath) ?>" alt="صورة العضو">
<?php else: ?>
<div class="no-photo">لا توجد صورة</div>
<?php endif; ?>
</div>
<div class="info-side">
<div class="club-header">
<?php if ($logoUrl): ?>
<div class="front-logo"><img src="<?= e($logoUrl) ?>" alt="Logo"></div>
<?php endif; ?>
<div class="club-name"><?= e($clubNameEn) ?><?= e($clubNameAr) ?></div>
<div class="club-name"><?= e($clubNameAr) ?></div>
<?php if ($showSub): ?>
<div class="club-subtitle"><?= e($clubSubtitle) ?></div>
<?php endif; ?>
</div>
<div class="member-details">
<div class="member-name"><?= e($carnet['full_name_ar']) ?></div>
<?php if ($showEn && !empty($carnet['full_name_en'])): ?>
<div class="member-name-en"><?= e($carnet['full_name_en']) ?></div>
......@@ -106,16 +132,18 @@ $fieldValues = [
<?php if ($showType): ?>
<div class="member-type"><?= $carnet['carnet_type'] === 'seasonal' ? 'عضوية موسمية' : 'عضو عامل' ?></div>
<?php endif; ?>
</div>
<div class="carnet-num"><?= e($carnet['carnet_number']) ?></div>
</div>
<?php if ($showQr): ?>
<div class="qr-area"><?= $qrSvg ?></div>
<?php endif; ?>
<div class="carnet-num"><?= e($carnet['carnet_number']) ?></div>
</div>
<!-- Back -->
<div class="carnet-back">
<?php if ($showStrip): ?>
<div class="branch-strip"><?= e($carnet['branch_name'] ?? 'فرع شيراتون') ?></div>
<div class="branch-strip"><?= e($carnet['branch_name'] ?? '') ?></div>
<?php endif; ?>
<div class="back-header">
<?php if ($logoUrl): ?>
......
......@@ -14,6 +14,7 @@ use App\Modules\Members\Services\NationalIdParser;
use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Forms\Services\FormBridge;
use App\Modules\Cashier\Services\PaymentRequestService;
use App\Shared\Services\PhotoUploadService;
class ChildController extends Controller
......@@ -140,6 +141,16 @@ class ChildController extends Controller
$errors[] = 'لا يمكن إضافة أبناء فوق ' . $childMaxAge . ' سنة';
}
$photoFile = $_FILES['photo'] ?? [];
if (!PhotoUploadService::isUploaded($photoFile)) {
$errors[] = 'الصورة الشخصية مطلوبة';
} else {
$photoValidation = PhotoUploadService::validate($photoFile);
if (!$photoValidation['valid']) {
$errors = array_merge($errors, $photoValidation['errors']);
}
}
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
......@@ -184,6 +195,11 @@ class ChildController extends Controller
'remarks' => $data['remarks'] ?? null,
]);
$photoResult = PhotoUploadService::upload($photoFile, 'children', (int) $child->id);
if ($photoResult) {
$child->update(['photo_path' => $photoResult['path']]);
}
if (FormBridge::exists('ADDITION_CHILD')) {
FormBridge::submit('ADDITION_CHILD', $data, (int) $memberId, 'إضافة ابن: ' . trim($data['full_name_ar']));
}
......@@ -274,6 +290,16 @@ class ChildController extends Controller
}
}
if (!empty($_FILES['photo']) && PhotoUploadService::isUploaded($_FILES['photo'])) {
$photoValidation = PhotoUploadService::validate($_FILES['photo']);
if ($photoValidation['valid']) {
$photoResult = PhotoUploadService::upload($_FILES['photo'], 'children', (int) $id);
if ($photoResult) {
$updateData['photo_path'] = $photoResult['path'];
}
}
}
if (!empty($updateData)) {
$child->update($updateData);
}
......
......@@ -11,7 +11,7 @@
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">← العودة للعضو</a>
</div>
<form method="POST" action="/members/<?= (int) $member['id'] ?>/children">
<form method="POST" action="/members/<?= (int) $member['id'] ?>/children" enctype="multipart/form-data">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:20px;">
......@@ -82,6 +82,17 @@
</div>
</div>
<!-- Profile Photo - MANDATORY -->
<div class="card" style="margin-bottom:20px;border-right:4px solid #7C3AED;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#7C3AED;">الصورة الشخصية / Profile Photo</h3>
<small style="color:#6B7280;">صورة واضحة للوجه — مطلوبة لإصدار الكارنيه</small>
</div>
<div style="padding:20px;">
<?php $currentPhoto = null; $required = true; $fieldName = 'photo'; $label = 'صورة الابن/الابنة'; include App::getInstance()->basePath() . '/app/Shared/Components/photo-upload-field.php'; ?>
</div>
</div>
<div style="display:flex;gap:10px;">
<button type="submit" class="btn btn-primary">إضافة الابن/الابنة</button>
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">إلغاء</a>
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تعديل بيانات الابن: <?= e($child->full_name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="/members/<?= (int) $member['id'] ?>/children/<?= (int) $child->id ?>">
<form method="POST" action="/members/<?= (int) $member['id'] ?>/children/<?= (int) $child->id ?>" enctype="multipart/form-data">
<?= csrf_field() ?>
<div class="card" style="padding:20px;margin-bottom:20px;">
<h3 style="color:#0D7377;margin-bottom:15px;">البيانات الأساسية (للقراءة فقط)</h3>
......@@ -23,6 +23,12 @@
<div class="form-group" style="grid-column:1/-1;"><label class="form-label">ملاحظات</label><textarea name="remarks" class="form-textarea" rows="2"><?= e($child->remarks ?? '') ?></textarea></div>
</div>
</div>
<!-- Profile Photo -->
<div class="card" style="padding:20px;margin-bottom:20px;border-right:4px solid #7C3AED;">
<h3 style="color:#7C3AED;margin-bottom:15px;">الصورة الشخصية</h3>
<?php $currentPhoto = $child->photo_path ?? null; $required = false; $fieldName = 'photo'; $label = 'الصورة الشخصية'; include App::getInstance()->basePath() . '/app/Shared/Components/photo-upload-field.php'; ?>
</div>
<button type="submit" class="btn btn-primary">حفظ التعديلات</button>
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">إلغاء</a>
</form>
......
......@@ -40,11 +40,21 @@ class CoachController extends Controller
$disciplines = $db->select(
"SELECT id, name_ar FROM sport_disciplines WHERE is_active = 1 AND is_archived = 0 ORDER BY name_ar ASC"
);
$academies = $db->select(
"SELECT id, name_ar FROM academies WHERE is_active = 1 AND is_archived = 0 ORDER BY name_ar ASC"
);
$academyLevels = $db->select(
"SELECT id, academy_id, name_ar FROM academy_levels WHERE is_active = 1 ORDER BY level_order ASC"
);
return $this->view('Coaches.Views.create', [
'disciplines' => $disciplines,
'employmentTypes' => Coach::getEmploymentTypes(),
'paymentModels' => Coach::getPaymentModels(),
'coachTypes' => Coach::getCoachTypes(),
'academies' => $academies,
'academyLevels' => $academyLevels,
'roles' => Coach::getRoles(),
]);
}
......@@ -62,6 +72,11 @@ class CoachController extends Controller
}
}
$coachType = trim((string) $request->post('coach_type', 'independent'));
if (!array_key_exists($coachType, Coach::getCoachTypes())) {
$coachType = 'independent';
}
$data = [
'full_name_ar' => trim((string) $request->post('full_name_ar', '')),
'full_name_en' => trim((string) $request->post('full_name_en', '')) ?: null,
......@@ -73,6 +88,7 @@ class CoachController extends Controller
'gender' => $gender,
'bio_ar' => trim((string) $request->post('bio_ar', '')) ?: null,
'employment_type' => trim((string) $request->post('employment_type', 'contract')),
'coach_type' => $coachType,
'max_players_default'=> (int) $request->post('max_players_default', 20),
'hourly_rate' => $request->post('hourly_rate', '') !== '' ? (float) $request->post('hourly_rate') : null,
'session_rate' => $request->post('session_rate', '') !== '' ? (float) $request->post('session_rate') : null,
......@@ -124,6 +140,15 @@ class CoachController extends Controller
$coach = CoachService::createCoach($data, $disciplineIds);
if ($coachType === 'academy') {
$academyId = (int) $request->post('academy_id', 0);
if ($academyId > 0) {
$levelId = $request->post('academy_level_id', '') !== '' ? (int) $request->post('academy_level_id') : null;
$role = trim((string) $request->post('academy_role', 'coach'));
CoachService::assignToAcademy((int) $coach->id, $academyId, $levelId, $role, null, date('Y-m-d'));
}
}
return $this->redirect('/coaches/' . $coach->id)->withSuccess('تم إضافة المدرب بنجاح');
}
......@@ -170,12 +195,39 @@ class CoachController extends Controller
);
$coachDisciplineIds = array_column($coach->getDisciplines(), 'discipline_id');
$academies = $db->select(
"SELECT id, name_ar FROM academies WHERE is_active = 1 AND is_archived = 0 ORDER BY name_ar ASC"
);
$academyLevels = $db->select(
"SELECT id, academy_id, name_ar FROM academy_levels WHERE is_active = 1 ORDER BY level_order ASC"
);
$currentAcademyId = null;
$currentLevelId = null;
$currentRole = 'coach';
$activeAssignment = $db->selectOne(
"SELECT academy_id, level_id, role FROM coach_academy_assignments WHERE coach_id = ? AND is_active = 1 ORDER BY id DESC LIMIT 1",
[(int) $id]
);
if ($activeAssignment) {
$currentAcademyId = $activeAssignment['academy_id'];
$currentLevelId = $activeAssignment['level_id'];
$currentRole = $activeAssignment['role'] ?? 'coach';
}
return $this->view('Coaches.Views.edit', [
'coach' => $coach,
'disciplines' => $disciplines,
'coachDisciplineIds' => $coachDisciplineIds,
'employmentTypes' => Coach::getEmploymentTypes(),
'paymentModels' => Coach::getPaymentModels(),
'coachTypes' => Coach::getCoachTypes(),
'academies' => $academies,
'academyLevels' => $academyLevels,
'roles' => Coach::getRoles(),
'currentAcademyId' => $currentAcademyId,
'currentLevelId' => $currentLevelId,
'currentRole' => $currentRole,
]);
}
......@@ -198,6 +250,11 @@ class CoachController extends Controller
}
}
$coachType = trim((string) $request->post('coach_type', 'independent'));
if (!array_key_exists($coachType, Coach::getCoachTypes())) {
$coachType = 'independent';
}
$data = [
'full_name_ar' => trim((string) $request->post('full_name_ar', '')),
'full_name_en' => trim((string) $request->post('full_name_en', '')) ?: null,
......@@ -209,6 +266,7 @@ class CoachController extends Controller
'gender' => $gender,
'bio_ar' => trim((string) $request->post('bio_ar', '')) ?: null,
'employment_type' => trim((string) $request->post('employment_type', 'contract')),
'coach_type' => $coachType,
'max_players_default'=> (int) $request->post('max_players_default', 20),
'hourly_rate' => $request->post('hourly_rate', '') !== '' ? (float) $request->post('hourly_rate') : null,
'session_rate' => $request->post('session_rate', '') !== '' ? (float) $request->post('session_rate') : null,
......@@ -260,6 +318,31 @@ class CoachController extends Controller
CoachService::syncDisciplines((int) $id, $disciplineIds);
}
$db = App::getInstance()->db();
$activeAssignment = $db->selectOne(
"SELECT id, academy_id FROM coach_academy_assignments WHERE coach_id = ? AND is_active = 1 ORDER BY id DESC LIMIT 1",
[(int) $id]
);
if ($coachType === 'academy') {
$academyId = (int) $request->post('academy_id', 0);
if ($academyId > 0) {
$needsNew = !$activeAssignment || (int) $activeAssignment['academy_id'] !== $academyId;
if ($needsNew) {
if ($activeAssignment) {
CoachService::unassignFromAcademy((int) $activeAssignment['id']);
}
$levelId = $request->post('academy_level_id', '') !== '' ? (int) $request->post('academy_level_id') : null;
$role = trim((string) $request->post('academy_role', 'coach'));
CoachService::assignToAcademy((int) $id, $academyId, $levelId, $role, null, date('Y-m-d'));
}
}
} else {
if ($activeAssignment) {
CoachService::unassignFromAcademy((int) $activeAssignment['id']);
}
}
return $this->redirect('/coaches/' . $id)->withSuccess('تم تحديث بيانات المدرب بنجاح');
}
......
......@@ -27,6 +27,7 @@ class Coach extends Model
'bio_ar',
'certifications_json',
'employment_type',
'coach_type',
'employee_id',
'max_players_default',
'hourly_rate',
......@@ -45,6 +46,15 @@ class Coach extends Model
];
}
public static function getCoachTypes(): array
{
return [
'independent' => 'مدرب مستقل',
'academy' => 'مدرب أكاديمية',
'club_employee' => 'موظف نادي',
];
}
public static function getPaymentModels(): array
{
return [
......
......@@ -86,6 +86,14 @@
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;">
<div class="form-group">
<label class="form-label">نوع المدرب <span style="color:#DC2626;">*</span></label>
<select name="coach_type" id="coachTypeSelect" class="form-select" required>
<?php foreach ($coachTypes as $key => $label): ?>
<option value="<?= e($key) ?>" <?= (old('coach_type') ?? 'independent') === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">نوع التوظيف <span style="color:#DC2626;">*</span></label>
<select name="employment_type" class="form-select" required>
......@@ -102,11 +110,44 @@
<?php endforeach; ?>
</select>
</div>
</div>
<!-- Academy Assignment (shown when coach_type = academy) -->
<div id="academyFields" style="display:none;margin-top:15px;padding:15px;background:#F5F3FF;border:1px solid #8B5CF630;border-radius:10px;">
<div style="font-size:13px;font-weight:700;color:#7C3AED;margin-bottom:12px;"><i data-lucide="building-2" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> تعيين الأكاديمية</div>
<div style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:15px;">
<div class="form-group" style="margin:0;">
<label class="form-label">الأكاديمية <span style="color:#DC2626;">*</span></label>
<select name="academy_id" id="academySelect" class="form-select">
<option value="">-- اختر الأكاديمية --</option>
<?php foreach ($academies as $acad): ?>
<option value="<?= (int) $acad['id'] ?>" <?= old('academy_id') == $acad['id'] ? 'selected' : '' ?>><?= e($acad['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label">المستوى</label>
<select name="academy_level_id" class="form-select">
<option value="">-- كل المستويات --</option>
<?php foreach ($academyLevels as $lvl): ?>
<option value="<?= (int) $lvl['id'] ?>" data-academy="<?= (int) $lvl['academy_id'] ?>" <?= old('academy_level_id') == $lvl['id'] ? 'selected' : '' ?>><?= e($lvl['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label">الدور</label>
<select name="academy_role" class="form-select">
<?php foreach ($roles as $key => $label): ?>
<option value="<?= e($key) ?>" <?= (old('academy_role') ?? 'coach') === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:20px;margin-top:15px;">
<div class="form-group">
<label class="form-label">الحد الأقصى للاعبين</label>
<input type="number" name="max_players_default" value="<?= e(old('max_players_default') ?? '20') ?>" class="form-input" min="1" max="100" style="direction:ltr;text-align:left;">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;margin-top:15px;">
<div class="form-group">
<label class="form-label">سعر الساعة (ج.م)</label>
......@@ -181,6 +222,21 @@
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
// Coach type toggle
var coachTypeSelect = document.getElementById('coachTypeSelect');
var academyFields = document.getElementById('academyFields');
function toggleAcademyFields() {
academyFields.style.display = coachTypeSelect.value === 'academy' ? 'block' : 'none';
var acadSelect = document.getElementById('academySelect');
if (coachTypeSelect.value === 'academy') {
acadSelect.setAttribute('required', 'required');
} else {
acadSelect.removeAttribute('required');
}
}
coachTypeSelect.addEventListener('change', toggleAcademyFields);
toggleAcademyFields();
var photoInput = document.getElementById('photoInput');
if (photoInput) {
photoInput.addEventListener('change', function() {
......
......@@ -93,6 +93,14 @@
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;">
<div class="form-group">
<label class="form-label">نوع المدرب <span style="color:#DC2626;">*</span></label>
<select name="coach_type" id="coachTypeSelect" class="form-select" required>
<?php foreach ($coachTypes as $key => $label): ?>
<option value="<?= e($key) ?>" <?= (old('coach_type') ?: ($coach->coach_type ?? 'independent')) === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">نوع التوظيف <span style="color:#DC2626;">*</span></label>
<select name="employment_type" class="form-select" required>
......@@ -109,11 +117,44 @@
<?php endforeach; ?>
</select>
</div>
</div>
<!-- Academy Assignment (shown when coach_type = academy) -->
<div id="academyFields" style="display:none;margin-top:15px;padding:15px;background:#F5F3FF;border:1px solid #8B5CF630;border-radius:10px;">
<div style="font-size:13px;font-weight:700;color:#7C3AED;margin-bottom:12px;"><i data-lucide="building-2" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> تعيين الأكاديمية</div>
<div style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:15px;">
<div class="form-group" style="margin:0;">
<label class="form-label">الأكاديمية <span style="color:#DC2626;">*</span></label>
<select name="academy_id" id="academySelect" class="form-select">
<option value="">-- اختر الأكاديمية --</option>
<?php foreach ($academies as $acad): ?>
<option value="<?= (int) $acad['id'] ?>" <?= (old('academy_id') ?: ($currentAcademyId ?? '')) == $acad['id'] ? 'selected' : '' ?>><?= e($acad['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label">المستوى</label>
<select name="academy_level_id" class="form-select">
<option value="">-- كل المستويات --</option>
<?php foreach ($academyLevels as $lvl): ?>
<option value="<?= (int) $lvl['id'] ?>" data-academy="<?= (int) $lvl['academy_id'] ?>" <?= (old('academy_level_id') ?: ($currentLevelId ?? '')) == $lvl['id'] ? 'selected' : '' ?>><?= e($lvl['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label">الدور</label>
<select name="academy_role" class="form-select">
<?php foreach ($roles as $key => $label): ?>
<option value="<?= e($key) ?>" <?= (old('academy_role') ?: ($currentRole ?? 'coach')) === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:20px;margin-top:15px;">
<div class="form-group">
<label class="form-label">الحد الأقصى للاعبين</label>
<input type="number" name="max_players_default" value="<?= e(old('max_players_default') ?: (string)($coach->max_players_default ?? 20)) ?>" class="form-input" min="1" max="100" style="direction:ltr;text-align:left;">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;margin-top:15px;">
<div class="form-group">
<label class="form-label">سعر الساعة (ج.م)</label>
......@@ -198,6 +239,21 @@
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
// Coach type toggle
var coachTypeSelect = document.getElementById('coachTypeSelect');
var academyFields = document.getElementById('academyFields');
function toggleAcademyFields() {
academyFields.style.display = coachTypeSelect.value === 'academy' ? 'block' : 'none';
var acadSelect = document.getElementById('academySelect');
if (coachTypeSelect.value === 'academy') {
acadSelect.setAttribute('required', 'required');
} else {
acadSelect.removeAttribute('required');
}
}
coachTypeSelect.addEventListener('change', toggleAcademyFields);
toggleAcademyFields();
var photoInput = document.getElementById('photoInput');
if (photoInput) {
photoInput.addEventListener('change', function() {
......
......@@ -20,6 +20,7 @@ use App\Modules\Pricing\Models\SpecialDiscount;
use App\Modules\Workflow\Services\WorkflowEngine;
use App\Modules\Forms\Services\FormBridge;
use App\Core\Logger;
use App\Shared\Services\PhotoUploadService;
class MemberController extends Controller
{
......@@ -112,6 +113,16 @@ class MemberController extends Controller
$errors[] = 'الحد الأدنى لسن العضوية العاملة ' . $workingMinAge . ' سنة (السن الحالي: ' . $ageYears . ')';
}
$photoFile = $_FILES['photo'] ?? [];
if (!PhotoUploadService::isUploaded($photoFile)) {
$errors[] = 'الصورة الشخصية مطلوبة';
} else {
$photoValidation = PhotoUploadService::validate($photoFile);
if (!$photoValidation['valid']) {
$errors = array_merge($errors, $photoValidation['errors']);
}
}
if (!empty($errors)) { $session = App::getInstance()->session(); $session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors)); $session->flash('_old_input', $request->all()); return $this->redirect('/members/create'); }
$formNumber = MemberNumberGenerator::next();
......@@ -119,6 +130,11 @@ class MemberController extends Controller
$nationality = ($idType === 'passport') ? 'أجنبي' : 'مصري';
$member = Member::create(['full_name_ar' => $fullNameAr, 'national_id' => $nationalId ?: null, 'passport_number' => $request->post('passport_number') ?: null, 'id_type' => $idType, 'date_of_birth' => $dob, 'age_years' => $ageYears, 'age_months' => $ageMonths, 'gender' => $gender, 'governorate_code' => $govCode, 'phone_mobile' => $phoneMobile, 'branch_id' => $branchId, 'nationality' => $nationality, 'form_number' => (string) $formNumber, 'form_date' => date('Y-m-d'), 'status' => 'potential', 'membership_type' => 'working', 'member_category' => 'working_member']);
$photoResult = PhotoUploadService::upload($photoFile, 'members', (int) $member->id);
if ($photoResult) {
$member->update(['photo_path' => $photoResult['path']]);
}
if (WorkflowEngine::hasDefinition('new_membership')) {
try {
$wfInstance = WorkflowEngine::createInstance('new_membership', 'members', (int) $member->id);
......@@ -658,10 +674,48 @@ class MemberController extends Controller
}
}
if (!empty($_FILES['photo']) && PhotoUploadService::isUploaded($_FILES['photo'])) {
$photoValidation = PhotoUploadService::validate($_FILES['photo']);
if ($photoValidation['valid']) {
$photoResult = PhotoUploadService::upload($_FILES['photo'], 'members', (int) $id);
if ($photoResult) {
$update['photo_path'] = $photoResult['path'];
}
}
}
if (!empty($update)) $member->update($update);
return $this->redirect('/members/' . $id)->withSuccess('تم تحديث البيانات');
}
public function uploadPhoto(Request $request, string $id): Response
{
$member = Member::find((int) $id);
if (!$member || $member->is_archived) {
return $this->redirect('/members')->withError('العضو غير موجود');
}
$photoFile = $_FILES['photo'] ?? [];
if (!PhotoUploadService::isUploaded($photoFile)) {
return $this->redirect('/members/' . $id)->withError('لم يتم رفع صورة');
}
$validation = PhotoUploadService::validate($photoFile);
if (!$validation['valid']) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $validation['errors']));
return $this->redirect('/members/' . $id);
}
$result = PhotoUploadService::upload($photoFile, 'members', (int) $id);
if ($result) {
$member->update(['photo_path' => $result['path']]);
return $this->redirect('/members/' . $id)->withSuccess('تم رفع الصورة بنجاح');
}
return $this->redirect('/members/' . $id)->withError('حدث خطأ أثناء حفظ الصورة');
}
public function changeStatus(Request $request, string $id): Response
{
$member = Member::find((int) $id);
......
......@@ -9,6 +9,7 @@ return [
['GET', '/members/{id}', 'Members\Controllers\MemberController@show', ['auth'], 'member.view'],
['GET', '/members/{id}/edit', 'Members\Controllers\MemberController@edit', ['auth'], 'member.edit'],
['POST', '/members/{id}', 'Members\Controllers\MemberController@update', ['auth', 'csrf'], 'member.edit'],
['POST', '/members/{id}/photo', 'Members\Controllers\MemberController@uploadPhoto', ['auth', 'csrf'], 'member.edit'],
['POST', '/members/{id}/status', 'Members\Controllers\MemberController@changeStatus', ['auth', 'csrf'], 'member.change_status'],
['POST', '/members/{id}/pay-form-fee', 'Members\Controllers\MemberController@payFormFee', ['auth', 'csrf'], 'member.pay_form_fee'],
['POST', '/members/{id}/pay-membership', 'Members\Controllers\MemberController@payMembership',['auth', 'csrf'], 'member.pay_membership'],
......
<?php
/** @var \App\Modules\Members\Models\Member $member */
$hasPhoto = !empty($member->photo_path);
?>
<div style="display:flex;justify-content:space-between;align-items:center;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:15px;">
<div style="display:flex;align-items:center;gap:15px;">
<div style="width:70px;height:70px;border-radius:50%;overflow:hidden;border:3px solid <?= $hasPhoto ? '#0D7377' : '#DC2626' ?>;flex-shrink:0;background:#F3F4F6;display:flex;align-items:center;justify-content:center;">
<?php if ($hasPhoto): ?>
<img src="/<?= e($member->photo_path) ?>" alt="صورة العضو" style="width:100%;height:100%;object-fit:cover;">
<?php else: ?>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#9CA3AF" stroke-width="1.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<?php endif; ?>
</div>
<div>
<h2 style="margin:0;"><?= e($member->full_name_ar) ?></h2>
<div style="color:#6B7280;margin-top:4px;">رقم العضوية: <strong><?= e($member->membership_number ?: 'لم يُحدد') ?></strong></div>
<?php if (!$hasPhoto): ?>
<div style="margin-top:6px;">
<span style="background:#FEF2F2;color:#DC2626;padding:3px 10px;border-radius:4px;font-size:11px;font-weight:600;">⚠ لا توجد صورة — مطلوبة لإصدار الكارنيه</span>
</div>
<?php endif; ?>
</div>
</div>
<span style="padding:6px 16px;border-radius:20px;font-weight:700;color:#fff;background:<?= $member->getStatusColor() ?>;"><?= e($member->getStatusLabel()) ?></span>
</div>
......@@ -7,7 +7,7 @@
<div class="card" style="margin-bottom:20px;padding:20px;border-right:4px solid #D97706;">
<h3 style="color:#D97706;margin:0 0 10px;">تحديد رقم بداية الاستمارات</h3>
<p style="color:#6B7280;font-size:14px;margin-bottom:15px;">هذه المرة الأولى — يجب تحديد رقم أول استمارة في النظام</p>
<form method="POST" action="/members" id="member-form">
<form method="POST" action="/members" id="member-form" enctype="multipart/form-data">
<?= csrf_field() ?>
<div style="display:flex;gap:15px;align-items:end;margin-bottom:20px;">
<div class="form-group" style="flex:0 0 200px;">
......@@ -16,7 +16,7 @@
</div>
</div>
<?php else: ?>
<form method="POST" action="/members" id="member-form">
<form method="POST" action="/members" id="member-form" enctype="multipart/form-data">
<?= csrf_field() ?>
<?php endif; ?>
......@@ -144,6 +144,17 @@
</div>
</div>
<!-- Profile Photo - MANDATORY -->
<div class="card" style="margin-bottom:20px;border-right:4px solid #7C3AED;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#7C3AED;">الصورة الشخصية / Profile Photo</h3>
<small style="color:#6B7280;">صورة واضحة للوجه — مطلوبة لإصدار كارنيه العضوية</small>
</div>
<div style="padding:20px;">
<?php $currentPhoto = null; $required = true; $fieldName = 'photo'; $label = 'الصورة الشخصية'; include App::getInstance()->basePath() . '/app/Shared/Components/photo-upload-field.php'; ?>
</div>
</div>
<div style="display:flex;gap:10px;">
<button type="submit" class="btn btn-primary" style="padding:12px 30px;font-size:16px;">تسجيل وإنشاء الاستمارة</button>
<a href="/members" class="btn btn-outline" style="padding:12px 20px;">إلغاء</a>
......
......@@ -139,6 +139,12 @@
</div>
<?php endif; ?>
<!-- Profile Photo -->
<div class="card" style="margin-bottom:20px;padding:20px;border-right:4px solid #7C3AED;">
<h3 style="color:#7C3AED;margin-bottom:15px;">الصورة الشخصية</h3>
<?php $currentPhoto = $member->photo_path ?? null; $required = false; $fieldName = 'photo'; $label = 'الصورة الشخصية'; include App::getInstance()->basePath() . '/app/Shared/Components/photo-upload-field.php'; ?>
</div>
<button type="submit" class="btn btn-primary">حفظ التعديلات</button>
<a href="/members/<?= (int) $member->id ?>" class="btn btn-outline">إلغاء</a>
</form>
......
......@@ -65,14 +65,21 @@ class CoachController extends Controller
public function create(Request $request): Response
{
$db = App::getInstance()->db();
$disciplines = Discipline::getActive();
$employmentTypes = Coach::getEmploymentTypeOptions();
$paymentModels = Coach::getPaymentModelOptions();
$coachTypes = Coach::getCoachTypeOptions();
$academies = $db->select(
"SELECT id, name_ar FROM sa_academies WHERE is_active = 1 AND is_archived = 0 ORDER BY name_ar ASC"
);
return $this->view('SportsActivity.Views.coaches.create', [
'disciplines' => $disciplines,
'employmentTypes' => $employmentTypes,
'paymentModels' => $paymentModels,
'coachTypes' => $coachTypes,
'academies' => $academies,
]);
}
......@@ -134,6 +141,12 @@ class CoachController extends Controller
}
}
$coachType = trim((string) $request->post('coach_type', 'independent'));
if (!array_key_exists($coachType, Coach::getCoachTypeOptions())) {
$coachType = 'independent';
}
$academyId = $coachType === 'academy' ? ((int) $request->post('academy_id', 0) ?: null) : null;
$coach = Coach::create([
'code' => $code,
'full_name_ar' => $fullNameAr,
......@@ -144,6 +157,8 @@ class CoachController extends Controller
'date_of_birth' => $dateOfBirth ?: null,
'gender' => $gender ?: null,
'employment_type' => $employmentType,
'coach_type' => $coachType,
'academy_id' => $academyId,
'payment_model' => $paymentModel,
'hourly_rate' => $hourlyRate !== '' ? (float) $hourlyRate : null,
'session_rate' => $sessionRate !== '' ? (float) $sessionRate : null,
......@@ -216,6 +231,10 @@ class CoachController extends Controller
$disciplines = Discipline::getActive();
$employmentTypes = Coach::getEmploymentTypeOptions();
$paymentModels = Coach::getPaymentModelOptions();
$coachTypes = Coach::getCoachTypeOptions();
$academies = $db->select(
"SELECT id, name_ar FROM sa_academies WHERE is_active = 1 AND is_archived = 0 ORDER BY name_ar ASC"
);
return $this->view('SportsActivity.Views.coaches.edit', [
'coach' => $coach,
......@@ -223,6 +242,8 @@ class CoachController extends Controller
'disciplines' => $disciplines,
'employmentTypes' => $employmentTypes,
'paymentModels' => $paymentModels,
'coachTypes' => $coachTypes,
'academies' => $academies,
]);
}
......@@ -296,6 +317,12 @@ class CoachController extends Controller
}
}
$coachType = trim((string) $request->post('coach_type', 'independent'));
if (!array_key_exists($coachType, Coach::getCoachTypeOptions())) {
$coachType = 'independent';
}
$academyId = $coachType === 'academy' ? ((int) $request->post('academy_id', 0) ?: null) : null;
$db->update('sa_coaches', [
'code' => $code,
'full_name_ar' => $fullNameAr,
......@@ -306,6 +333,8 @@ class CoachController extends Controller
'date_of_birth' => $dateOfBirth ?: null,
'gender' => $gender ?: null,
'employment_type' => $employmentType,
'coach_type' => $coachType,
'academy_id' => $academyId,
'payment_model' => $paymentModel,
'hourly_rate' => $hourlyRate !== '' ? (float) $hourlyRate : null,
'session_rate' => $sessionRate !== '' ? (float) $sessionRate : null,
......
......@@ -18,7 +18,7 @@ class Coach extends Model
protected static array $fillable = [
'code', 'full_name_ar', 'full_name_en', 'national_id', 'phone', 'email',
'date_of_birth', 'gender', 'photo_path', 'bio_ar', 'certifications_json',
'employment_type', 'employee_id', 'payment_model',
'employment_type', 'coach_type', 'academy_id', 'employee_id', 'payment_model',
'hourly_rate', 'session_rate', 'monthly_rate',
'max_groups', 'is_active', 'branch_id',
];
......@@ -32,6 +32,15 @@ class Coach extends Model
];
}
public static function getCoachTypeOptions(): array
{
return [
'independent' => 'مدرب مستقل',
'academy' => 'مدرب أكاديمية',
'club_employee' => 'موظف نادي',
];
}
public static function getPaymentModelOptions(): array
{
return [
......
......@@ -71,7 +71,15 @@
<h3 style="margin:0;color:#059669;font-size:15px;">بيانات التوظيف</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;">
<div class="form-group">
<label class="form-label">نوع المدرب <span style="color:#DC2626;">*</span></label>
<select name="coach_type" id="coachTypeSelect" class="form-select" required>
<?php foreach ($coachTypes as $key => $label): ?>
<option value="<?= e($key) ?>" <?= (old('coach_type') ?? 'independent') === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">نوع التوظيف <span style="color:#DC2626;">*</span></label>
<select name="employment_type" class="form-select" required>
......@@ -91,6 +99,18 @@
</select>
</div>
</div>
<!-- Academy selector (shown when coach_type = academy) -->
<div id="academyFields" style="display:none;margin-top:15px;padding:15px;background:#F5F3FF;border:1px solid #8B5CF630;border-radius:10px;">
<div style="font-size:13px;font-weight:700;color:#7C3AED;margin-bottom:10px;"><i data-lucide="building-2" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> الأكاديمية</div>
<div class="form-group" style="margin:0;">
<select name="academy_id" id="academySelect" class="form-select">
<option value="">-- اختر الأكاديمية --</option>
<?php foreach ($academies as $acad): ?>
<option value="<?= (int) $acad['id'] ?>" <?= old('academy_id') == $acad['id'] ? 'selected' : '' ?>><?= e($acad['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:20px;margin-top:15px;">
<div class="form-group">
<label class="form-label">سعر الساعة (ج.م)</label>
......@@ -181,6 +201,21 @@ document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
}
// Coach type toggle
var coachTypeSelect = document.getElementById('coachTypeSelect');
var academyFields = document.getElementById('academyFields');
function toggleAcademyFields() {
academyFields.style.display = coachTypeSelect.value === 'academy' ? 'block' : 'none';
var acadSelect = document.getElementById('academySelect');
if (coachTypeSelect.value === 'academy') {
acadSelect.setAttribute('required', 'required');
} else {
acadSelect.removeAttribute('required');
}
}
coachTypeSelect.addEventListener('change', toggleAcademyFields);
toggleAcademyFields();
var container = document.getElementById('disciplinesContainer');
var template = document.getElementById('disciplineRowTemplate');
var addBtn = document.getElementById('addDisciplineBtn');
......
......@@ -80,7 +80,15 @@ $val = function(string $field) use ($coach) {
<h3 style="margin:0;color:#059669;font-size:15px;">بيانات التوظيف</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;">
<div class="form-group">
<label class="form-label">نوع المدرب <span style="color:#DC2626;">*</span></label>
<select name="coach_type" id="coachTypeSelect" class="form-select" required>
<?php foreach ($coachTypes as $key => $label): ?>
<option value="<?= e($key) ?>" <?= ($val('coach_type') ?: 'independent') === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">نوع التوظيف <span style="color:#DC2626;">*</span></label>
<select name="employment_type" class="form-select" required>
......@@ -100,6 +108,18 @@ $val = function(string $field) use ($coach) {
</select>
</div>
</div>
<!-- Academy selector (shown when coach_type = academy) -->
<div id="academyFields" style="display:none;margin-top:15px;padding:15px;background:#F5F3FF;border:1px solid #8B5CF630;border-radius:10px;">
<div style="font-size:13px;font-weight:700;color:#7C3AED;margin-bottom:10px;"><i data-lucide="building-2" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> الأكاديمية</div>
<div class="form-group" style="margin:0;">
<select name="academy_id" id="academySelect" class="form-select">
<option value="">-- اختر الأكاديمية --</option>
<?php foreach ($academies as $acad): ?>
<option value="<?= (int) $acad['id'] ?>" <?= $val('academy_id') == $acad['id'] ? 'selected' : '' ?>><?= e($acad['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:20px;margin-top:15px;">
<div class="form-group">
<label class="form-label">سعر الساعة (ج.م)</label>
......@@ -213,6 +233,21 @@ document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
}
// Coach type toggle
var coachTypeSelect = document.getElementById('coachTypeSelect');
var academyFields = document.getElementById('academyFields');
function toggleAcademyFields() {
academyFields.style.display = coachTypeSelect.value === 'academy' ? 'block' : 'none';
var acadSelect = document.getElementById('academySelect');
if (coachTypeSelect.value === 'academy') {
acadSelect.setAttribute('required', 'required');
} else {
acadSelect.removeAttribute('required');
}
}
coachTypeSelect.addEventListener('change', toggleAcademyFields);
toggleAcademyFields();
var container = document.getElementById('disciplinesContainer');
var template = document.getElementById('disciplineRowTemplate');
var addBtn = document.getElementById('addDisciplineBtn');
......
......@@ -13,6 +13,7 @@ use App\Modules\Spouses\Services\SpouseFeeCalculator;
use App\Modules\Members\Services\NationalIdParser;
use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Cashier\Services\PaymentRequestService;
use App\Shared\Services\PhotoUploadService;
class SpouseController extends Controller
......@@ -155,6 +156,16 @@ class SpouseController extends Controller
$errors[] = 'الحد الأدنى للسن ' . $spouseMinAge . ' سنة';
}
$photoFile = $_FILES['photo'] ?? [];
if (!PhotoUploadService::isUploaded($photoFile)) {
$errors[] = 'الصورة الشخصية مطلوبة';
} else {
$photoValidation = PhotoUploadService::validate($photoFile);
if (!$photoValidation['valid']) {
$errors = array_merge($errors, $photoValidation['errors']);
}
}
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
......@@ -206,6 +217,11 @@ class SpouseController extends Controller
'status' => ($hasFee || $pendingCalc) ? 'pending_payment' : 'active',
]);
$photoResult = PhotoUploadService::upload($photoFile, 'spouses', (int) $spouse->id);
if ($photoResult) {
$spouse->update(['photo_path' => $photoResult['path']]);
}
EventBus::dispatch('spouse.added', [
'member_id' => (int) $memberId,
'spouse_id' => (int) $spouse->id,
......@@ -302,6 +318,16 @@ class SpouseController extends Controller
$updateData['qualification_id'] = (int) $data['qualification_id'];
}
if (!empty($_FILES['photo']) && PhotoUploadService::isUploaded($_FILES['photo'])) {
$photoValidation = PhotoUploadService::validate($_FILES['photo']);
if ($photoValidation['valid']) {
$photoResult = PhotoUploadService::upload($_FILES['photo'], 'spouses', (int) $id);
if ($photoResult) {
$updateData['photo_path'] = $photoResult['path'];
}
}
}
if (!empty($updateData)) {
$spouse->update($updateData);
}
......
......@@ -23,7 +23,7 @@ $genderValue = $requiredGender;
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">← العودة للعضو</a>
</div>
<form method="POST" action="/members/<?= (int) $member['id'] ?>/spouses">
<form method="POST" action="/members/<?= (int) $member['id'] ?>/spouses" enctype="multipart/form-data">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:20px;">
......@@ -117,6 +117,17 @@ $genderValue = $requiredGender;
</div>
</div>
<!-- Profile Photo - MANDATORY -->
<div class="card" style="margin-bottom:20px;border-right:4px solid #7C3AED;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#7C3AED;">الصورة الشخصية / Profile Photo</h3>
<small style="color:#6B7280;">صورة واضحة للوجه — مطلوبة لإصدار الكارنيه</small>
</div>
<div style="padding:20px;">
<?php $currentPhoto = null; $required = true; $fieldName = 'photo'; $label = 'صورة ' . $spouseLabel; include App::getInstance()->basePath() . '/app/Shared/Components/photo-upload-field.php'; ?>
</div>
</div>
<div style="display:flex;gap:10px;">
<button type="submit" class="btn btn-primary">إضافة <?= $spouseLabel ?></button>
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">إلغاء</a>
......
......@@ -2,7 +2,7 @@
<?php $__template->section('title'); ?>تعديل بيانات الزوج: <?= e($spouse->full_name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="/members/<?= (int) $member['id'] ?>/spouses/<?= (int) $spouse->id ?>">
<form method="POST" action="/members/<?= (int) $member['id'] ?>/spouses/<?= (int) $spouse->id ?>" enctype="multipart/form-data">
<?= csrf_field() ?>
<div class="card" style="padding:20px;margin-bottom:20px;">
<h3 style="color:#0D7377;margin-bottom:15px;">البيانات الأساسية (للقراءة فقط)</h3>
......@@ -33,6 +33,12 @@
<div class="form-group" style="grid-column:1/-1;"><label class="form-label">عنوان العمل</label><input type="text" name="work_address" value="<?= e($spouse->work_address ?? '') ?>" class="form-input"></div>
</div>
</div>
<!-- Profile Photo -->
<div class="card" style="padding:20px;margin-bottom:20px;border-right:4px solid #7C3AED;">
<h3 style="color:#7C3AED;margin-bottom:15px;">الصورة الشخصية</h3>
<?php $currentPhoto = $spouse->photo_path ?? null; $required = false; $fieldName = 'photo'; $label = 'الصورة الشخصية'; include App::getInstance()->basePath() . '/app/Shared/Components/photo-upload-field.php'; ?>
</div>
<button type="submit" class="btn btn-primary">حفظ التعديلات</button>
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">إلغاء</a>
</form>
......
......@@ -12,6 +12,7 @@ use App\Modules\Temporary\Models\TemporaryMember;
use App\Modules\Temporary\Services\TemporaryFeeCalculator;
use App\Modules\Members\Services\NationalIdParser;
use App\Modules\Cashier\Services\PaymentRequestService;
use App\Shared\Services\PhotoUploadService;
class TemporaryController extends Controller
......@@ -148,6 +149,16 @@ class TemporaryController extends Controller
$catErrors = TemporaryFeeCalculator::validateCategory($category, $data, $member);
$errors = array_merge($errors, $catErrors);
$photoFile = $_FILES['photo'] ?? [];
if (!PhotoUploadService::isUploaded($photoFile)) {
$errors[] = 'الصورة الشخصية مطلوبة';
} else {
$photoValidation = PhotoUploadService::validate($photoFile);
if (!$photoValidation['valid']) {
$errors = array_merge($errors, $photoValidation['errors']);
}
}
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
......@@ -185,6 +196,11 @@ class TemporaryController extends Controller
'notes' => $data['notes'] ?? null,
]);
$photoResult = PhotoUploadService::upload($photoFile, 'temporary', (int) $temp->id);
if ($photoResult) {
$temp->update(['photo_path' => $photoResult['path']]);
}
EventBus::dispatch('temporary.added', [
'member_id' => (int) $memberId,
'temporary_id' => (int) $temp->id,
......
......@@ -7,7 +7,7 @@
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">← العودة للعضو</a>
</div>
<form method="POST" action="/members/<?= (int) $member['id'] ?>/temporary">
<form method="POST" action="/members/<?= (int) $member['id'] ?>/temporary" enctype="multipart/form-data">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;color:#0D7377;">بيانات العضو المؤقت</h3></div>
......@@ -71,6 +71,17 @@
</div>
</div>
</div>
<!-- Profile Photo - MANDATORY -->
<div class="card" style="margin-bottom:20px;border-right:4px solid #7C3AED;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#7C3AED;">الصورة الشخصية / Profile Photo</h3>
<small style="color:#6B7280;">صورة واضحة للوجه — مطلوبة لإصدار الكارنيه</small>
</div>
<div style="padding:20px;">
<?php $currentPhoto = null; $required = true; $fieldName = 'photo'; $label = 'صورة العضو المؤقت'; include App::getInstance()->basePath() . '/app/Shared/Components/photo-upload-field.php'; ?>
</div>
</div>
<div style="display:flex;gap:10px;">
<button type="submit" class="btn btn-primary">إضافة العضو المؤقت</button>
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">إلغاء</a>
......
......@@ -611,6 +611,16 @@ final class TutorialRegistry
['title' => 'اختيار المدرب والفترة', 'body' => 'حدد المدرب والفترة الزمنية. النظام يحسب: عدد الحصص، نسبة الحضور، تقييمات اللاعبين.'],
['title' => 'إضافة تقييم', 'body' => 'اضغط <span class="field">تقييم جديد</span>. أدخل الملاحظات والدرجة (1-5).<span class="success">التقييمات تُراكم وتظهر في الملف الشخصي للمدرب.</span>'],
],
'coaches.coach-type-academy' => [
['title' => 'تحديد نوع المدرب', 'body' => 'عند إنشاء أو تعديل مدرب، حدد <span class="field">نوع المدرب</span>:<ul><li><strong>مدرب مستقل</strong> — يعمل بشكل حر</li><li><strong>مدرب أكاديمية</strong> — تابع لأكاديمية محددة</li><li><strong>موظف نادي</strong> — موظف رسمي بالنادي</li></ul>'],
['title' => 'ربط المدرب بأكاديمية', 'body' => 'عند اختيار <span class="field">مدرب أكاديمية</span> يظهر حقل إضافي لاختيار الأكاديمية والمستوى والدور (مدرب أول/مدرب/مساعد).<span class="info">إيرادات مجموعات المدرب المرتبط تُحسب ضمن إيرادات الأكاديمية في تسويات الحد الأدنى المضمون.</span>'],
['title' => 'تغيير النوع', 'body' => 'يمكن تغيير نوع المدرب لاحقاً من صفحة التعديل. عند التغيير من أكاديمية لمستقل — يُلغى التعيين تلقائياً.<span class="warn">تغيير النوع يؤثر على حسابات التسوية للشهر الحالي فقط — الأشهر السابقة لا تتأثر.</span>'],
],
'coaches.guarantee-report' => [
['title' => 'فتح تقرير الضمان', 'body' => 'من <span class="field">عقود الأكاديميات</span> > <span class="field">التسويات</span> > <span class="field">تقرير الحد الأدنى</span>.'],
['title' => 'تصفية البيانات', 'body' => 'حدد <span class="field">الأكاديمية</span> و<span class="field">الفترة الزمنية</span> (من شهر — إلى شهر). يعرض التقرير كل عقد نشط مع الإيرادات والعجز/الفائض لكل شهر.'],
['title' => 'قراءة التقرير', 'body' => 'لكل أكاديمية يُعرض:<ul><li>الحد الأدنى الشهري المطلوب</li><li>الإيرادات الفعلية (اشتراكات + مجموعات المدربين)</li><li>الفائض (أخضر) أو العجز (أحمر)</li><li>حالة التسوية</li></ul><span class="info">العجز = المبلغ الذي تدين به الأكاديمية للنادي.</span>'],
],
// ── CARNETS ──
'carnets.carnet-issuance-conditions' => [
['title' => 'فتح شروط الإصدار', 'body' => 'من <span class="field">الكارنيهات</span> > <span class="field">شروط الإصدار</span>.'],
......@@ -627,6 +637,12 @@ final class TutorialRegistry
['title' => 'إلغاء الكارنيه', 'body' => 'اضغط <span class="field">إلغاء</span>. حدد <span class="field">السبب</span> (مفقود، تالف، إيقاف عضوية).'],
['title' => 'إعادة إصدار', 'body' => 'بعد الإلغاء يمكن <span class="field">إعادة إصدار</span> كارنيه جديد برقم مختلف.<span class="warn">الكارنيه الملغي لا يمكن استخدامه للدخول.</span>'],
],
'carnets.dependent-carnet-issuance' => [
['title' => 'فتح ملف التابع', 'body' => 'من ملف العضو > <span class="field">التابعين</span>. اختر التابع المطلوب (زوج/زوجة، ابن/ابنة، مؤقت).'],
['title' => 'التحقق من الشروط', 'body' => 'لإصدار كارنيه تابع يجب:<ul><li>العضو الأصلي مؤهل لإصدار كارنيه</li><li>التابع لديه صورة شخصية مرفوعة</li><li>التابع في حالة نشطة</li></ul><span class="warn">إذا لم تتوفر صورة للتابع — لن يظهر زر إصدار الكارنيه.</span>'],
['title' => 'إصدار الكارنيه', 'body' => 'اضغط <span class="field">إصدار كارنيه</span> بجانب اسم التابع. النظام يُنشئ كارنيه منفصل يحتوي على:<ul><li>صورة التابع (كبيرة)</li><li>اسم التابع ونوع القرابة</li><li>صورة العضو الأصلي (صغيرة كمرجع)</li><li>رقم عضوية الأب/الأم</li><li>QR Code + رقم تسلسلي فريد</li></ul>'],
['title' => 'الطباعة', 'body' => 'اضغط <span class="field">طباعة</span> لمعاينة الكارنيه وطباعته.<span class="success">كل تابع يحصل على كارنيه مستقل برقم تسلسلي خاص. يمكن إعادة إصداره بشكل مستقل عن كارنيه العضو.</span>'],
],
'carnets.guest-entry' => [
['title' => 'تسجيل ضيف', 'body' => 'من <span class="field">الكارنيهات</span> > <span class="field">دخول الزوار</span> > <span class="field">زائر جديد</span>.'],
['title' => 'بيانات الضيف', 'body' => 'أدخل <span class="field">الاسم</span>، <span class="field">الرقم القومي</span>، و<span class="field">العضو المضيف</span>.'],
......@@ -1855,6 +1871,22 @@ final class TutorialRegistry
'category' => 'reports',
'order' => 4,
],
'coach-type-academy' => [
'title' => 'نوع المدرب وربط الأكاديمية',
'subtitle' => 'تحديد مستقل/أكاديمية/موظف وربط بأكاديمية',
'icon' => 'building-2',
'color' => '#7C3AED',
'category' => 'setup',
'order' => 5,
],
'guarantee-report' => [
'title' => 'تقرير الحد الأدنى المضمون',
'subtitle' => 'مقارنة إيرادات الأكاديميات بالحد المضمون',
'icon' => 'shield-check',
'color' => '#0284C7',
'category' => 'reports',
'order' => 6,
],
];
}
......@@ -1863,7 +1895,7 @@ final class TutorialRegistry
return [
'setup' => ['label' => 'التسجيل والإعداد', 'icon' => 'user-plus', 'color' => '#16A34A'],
'operations' => ['label' => 'الجدولة', 'icon' => 'calendar', 'color' => '#F59E0B'],
'reports' => ['label' => 'الأداء والتقييم', 'icon' => 'star', 'color' => '#DC2626'],
'reports' => ['label' => 'الأداء والتقارير', 'icon' => 'star', 'color' => '#DC2626'],
];
}
......@@ -1898,13 +1930,21 @@ final class TutorialRegistry
'category' => 'operations',
'order' => 3,
],
'dependent-carnet-issuance' => [
'title' => 'كارنيه التابعين',
'subtitle' => 'إصدار كارنيه منفصل لزوج/أبناء/مؤقتين',
'icon' => 'users',
'color' => '#7C3AED',
'category' => 'operations',
'order' => 4,
],
'guest-entry' => [
'title' => 'دخول الزوار',
'subtitle' => 'إصدار تصريح دخول مؤقت لضيف',
'icon' => 'door-open',
'color' => '#F59E0B',
'category' => 'operations',
'order' => 4,
'order' => 5,
],
];
}
......
......@@ -35,7 +35,15 @@
</ul>
<span class="info">النظام يحدد الفئة العمرية والرسوم تلقائياً من تاريخ الميلاد.</span></div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">الرسوم والدفع</h3><div class="tut-step-body">إذا كان العمر ≥ 5 سنوات:
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">رفع الصورة الشخصية (إلزامي)</h3><div class="tut-step-body">يجب رفع صورة شخصية واضحة للابن/الابنة:
<ul>
<li>الصيغ المقبولة: <span class="field">JPG</span>, <span class="field">PNG</span>, <span class="field">WEBP</span></li>
<li>الحد الأقصى: <span class="field">5 ميجابايت</span></li>
<li>يتم ضغط الصورة وإنشاء نسخة مصغرة تلقائياً</li>
</ul>
<span class="warn">الصورة مطلوبة لإصدار كارنيه التابع. لا يمكن إتمام الإضافة بدون صورة.</span></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">الرسوم والدفع</h3><div class="tut-step-body">إذا كان العمر ≥ 5 سنوات:
<ul>
<li>يتم حساب نسبة الرسوم المناسبة</li>
<li>يُنشئ طلب دفع ويُرسل للخزينة</li>
......
......@@ -34,7 +34,15 @@
<li><span class="field">رقم الهاتف</span></li>
</ul></div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">حساب الرسوم</h3><div class="tut-step-body">النظام يحسب الرسوم تلقائياً:
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">رفع الصورة الشخصية (إلزامي)</h3><div class="tut-step-body">يجب رفع صورة شخصية واضحة قبل إتمام الإضافة:
<ul>
<li>الصيغ المقبولة: <span class="field">JPG</span>, <span class="field">PNG</span>, <span class="field">WEBP</span></li>
<li>الحد الأقصى: <span class="field">5 ميجابايت</span></li>
<li>يتم ضغط الصورة وإنشاء نسخة مصغرة تلقائياً</li>
</ul>
<span class="warn">الصورة مطلوبة لإصدار كارنيه التابع. لا يمكن إتمام الإضافة بدون صورة.</span></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">حساب الرسوم</h3><div class="tut-step-body">النظام يحسب الرسوم تلقائياً:
<ul>
<li>يتحقق من ترتيب الزوجة (أولى/ثانية/ثالثة)</li>
<li>يحسب النسبة المطبقة</li>
......
......@@ -33,7 +33,15 @@
<li><span class="field">رقم الهاتف</span></li>
</ul></div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">الاشتراك الشهري</h3><div class="tut-step-body"><span class="info">العضو المؤقت يدفع اشتراكاً شهرياً (ليس لمرة واحدة). يتم إنشاء فاتورة شهرية تلقائياً ضمن دورة الاشتراكات.</span>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">رفع الصورة الشخصية (إلزامي)</h3><div class="tut-step-body">يجب رفع صورة شخصية واضحة للعضو المؤقت:
<ul>
<li>الصيغ المقبولة: <span class="field">JPG</span>, <span class="field">PNG</span>, <span class="field">WEBP</span></li>
<li>الحد الأقصى: <span class="field">5 ميجابايت</span></li>
<li>يتم ضغط الصورة وإنشاء نسخة مصغرة تلقائياً</li>
</ul>
<span class="warn">الصورة مطلوبة لإصدار كارنيه التابع. لا يمكن إتمام الإضافة بدون صورة.</span></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">الاشتراك الشهري</h3><div class="tut-step-body"><span class="info">العضو المؤقت يدفع اشتراكاً شهرياً (ليس لمرة واحدة). يتم إنشاء فاتورة شهرية تلقائياً ضمن دورة الاشتراكات.</span>
<span class="warn">إذا لم يُسدد 3 أشهر متتالية، يتم تعليق العضو المؤقت تلقائياً.</span></div></div>
<div class="tut-nav">
......
......@@ -27,7 +27,7 @@
<li>✅ الصورة الشخصية مرفوعة</li>
<li>✅ لا يوجد إيقاف نشط</li>
</ul>
<span class="warn">النظام يمنع إصدار الكارنيه إذا لم تتحقق أي من هذه الشروط.</span></div></div>
<span class="warn">النظام يمنع إصدار الكارنيه إذا لم تتحقق أي من هذه الشروط. الصورة الشخصية إلزامية وتظهر على الكارنيه.</span></div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">إجراء الإصدار</h3><div class="tut-step-body">من ملف العضو > <span class="field">الكارنيه</span> > <span class="field">إصدار كارنيه جديد</span>:
<ul>
......@@ -36,7 +36,26 @@
<li>يُعيّن تاريخ صلاحية (سنة واحدة)</li>
</ul></div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">تجديد الكارنيه</h3><div class="tut-step-body"><span class="info">عند انتهاء الصلاحية أو فقدان الكارنيه، يمكن إصدار بديل بنفس الشروط. الكارنيه البديل قد يحتاج رسوم إعادة إصدار.</span></div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">تصميم الكارنيه</h3><div class="tut-step-body">الكارنيه يتضمن:
<ul>
<li>صورة العضو الشخصية (تظهر بحجم كبير على يسار الكارنيه)</li>
<li>اسم النادي والشعار</li>
<li>اسم العضو بالعربي (والإنجليزي إن وُجد)</li>
<li>رقم العضوية ونوعها</li>
<li><span class="field">QR Code</span> للتحقق الإلكتروني</li>
</ul>
<span class="info">الخلف يحتوي على بيانات تفصيلية + شريط الفرع الملون. التصميم قابل للتخصيص من إعدادات النادي.</span></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">كارنيه التابعين</h3><div class="tut-step-body">يمكن إصدار كارنيهات منفصلة للتابعين (زوج/زوجة، أبناء، مؤقتين):
<ul>
<li>من ملف العضو > <span class="field">التابعين</span> > <span class="field">إصدار كارنيه</span> بجانب اسم التابع</li>
<li>يُشترط وجود صورة شخصية للتابع</li>
<li>الكارنيه يعرض صورة التابع + إشارة لرقم عضوية الأب/الأم</li>
<li>صورة صغيرة للعضو الأصلي تظهر كمرجع</li>
</ul>
<span class="success">كل تابع يحصل على كارنيه خاص به برقم تسلسلي مستقل.</span></div></div>
<div class="tut-step"><div class="tut-step-num">5</div><h3 class="tut-step-title">تجديد الكارنيه</h3><div class="tut-step-body"><span class="info">عند انتهاء الصلاحية أو فقدان الكارنيه، يمكن إصدار بديل بنفس الشروط. الكارنيه البديل قد يحتاج رسوم إعادة إصدار.</span></div></div>
<div class="tut-nav">
<a href="/tutorials/membership/fines-violations"><i data-lucide="arrow-right" style="width:14px;height:14px;"></i> المخالفات والغرامات</a>
......
......@@ -27,12 +27,19 @@
<li><span class="field">الاسم الكامل</span> — الاسم ثلاثي بالعربي</li>
<li><span class="field">الرقم القومي</span> — 14 رقم (يُستخرج منه تاريخ الميلاد والنوع تلقائياً)</li>
<li><span class="field">رقم الهاتف</span></li>
<li><span class="field">نوع العضوية</span> — عاملة / موسمية / رياضية / شرفية / أجنبية</li>
<li><span class="field">المؤهل</span> — عالي / متوسط / بدون (يحدد قيمة العضوية)</li>
<li><span class="field">الفرع</span> — اختيار الفرع المسجل فيه العضو</li>
</ul>
<span class="info">الحد الأدنى للعمر 21 سنة للعضوية العاملة. النظام يتحقق تلقائياً من الرقم القومي.</span></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">الحفظ</h3><div class="tut-step-body">عند الحفظ يتم:
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">رفع الصورة الشخصية (إلزامي)</h3><div class="tut-step-body">يجب رفع صورة شخصية واضحة للعضو قبل إتمام التسجيل:
<ul>
<li>الصيغ المقبولة: <span class="field">JPG</span>, <span class="field">PNG</span>, <span class="field">WEBP</span></li>
<li>الحد الأقصى: <span class="field">5 ميجابايت</span></li>
<li>يتم ضغط الصورة وإنشاء نسخة مصغرة تلقائياً</li>
</ul>
<span class="warn">لا يمكن إتمام التسجيل بدون رفع صورة شخصية. الصورة مطلوبة لإصدار كارنيه العضوية.</span></div></div>
<div class="tut-step"><div class="tut-step-num">5</div><h3 class="tut-step-title">الحفظ</h3><div class="tut-step-body">عند الحفظ يتم:
<ul>
<li>إنشاء رقم استمارة تلقائي (form_number)</li>
<li>تعيين حالة العضو = <span class="field">محتمل (potential)</span></li>
......
<?php
/**
* Photo upload field component with live preview.
*
* @var string|null $currentPhoto Existing photo path (relative, e.g. 'uploads/photos/members/...')
* @var bool $required Whether the field is mandatory
* @var string $fieldName Input field name (default: 'photo')
* @var string $label Label text
*/
$currentPhoto = $currentPhoto ?? null;
$required = $required ?? true;
$fieldName = $fieldName ?? 'photo';
$label = $label ?? 'الصورة الشخصية';
$fieldId = 'photo_upload_' . str_replace(['[', ']'], '_', $fieldName);
$previewId = 'photo_preview_' . str_replace(['[', ']'], '_', $fieldName);
?>
<div class="form-group photo-upload-group">
<label for="<?= e($fieldId) ?>" class="form-label">
<?= e($label) ?>
<?php if ($required): ?><span class="required-mark">*</span><?php endif; ?>
</label>
<div class="photo-upload-container" id="container_<?= e($fieldId) ?>">
<div class="photo-preview-area" id="<?= e($previewId) ?>">
<?php if ($currentPhoto): ?>
<img src="/<?= e($currentPhoto) ?>" alt="الصورة الحالية" class="photo-current">
<?php else: ?>
<div class="photo-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span>اضغط لرفع الصورة</span>
</div>
<?php endif; ?>
</div>
<input
type="file"
name="<?= e($fieldName) ?>"
id="<?= e($fieldId) ?>"
accept=".jpg,.jpeg,.png,.webp"
class="photo-file-input"
<?= $required && !$currentPhoto ? 'required' : '' ?>
>
<div class="photo-upload-actions">
<?php if ($currentPhoto): ?>
<span class="photo-status photo-status-ok">✓ صورة مرفوعة</span>
<?php endif; ?>
<label for="<?= e($fieldId) ?>" class="btn btn-sm btn-outline-primary photo-btn">
<?= $currentPhoto ? 'تغيير الصورة' : 'اختيار صورة' ?>
</label>
</div>
<small class="form-text text-muted">
الصيغ المقبولة: JPG, PNG, WEBP — الحد الأقصى: 5 ميجابايت
</small>
</div>
</div>
<style>
.photo-upload-group { margin-bottom: 1.5rem; }
.photo-upload-container { text-align: center; }
.photo-preview-area {
width: 180px;
height: 180px;
margin: 0 auto 12px;
border: 2px dashed #d1d5db;
border-radius: 12px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
cursor: pointer;
transition: border-color 0.2s;
}
.photo-preview-area:hover { border-color: #6366f1; }
.photo-preview-area img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #9ca3af;
}
.photo-placeholder span { font-size: 13px; }
.photo-file-input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
overflow: hidden;
}
.photo-upload-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 8px;
}
.photo-status { font-size: 13px; }
.photo-status-ok { color: #16a34a; }
.photo-btn { cursor: pointer; }
</style>
<script>
(function() {
var input = document.getElementById('<?= e($fieldId) ?>');
var preview = document.getElementById('<?= e($previewId) ?>');
preview.addEventListener('click', function() { input.click(); });
input.addEventListener('change', function() {
if (!this.files || !this.files[0]) return;
var file = this.files[0];
var maxSize = 5 * 1024 * 1024;
var allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowed.includes(file.type)) {
alert('صيغة الصورة غير مدعومة — يُقبل فقط JPG, PNG, WEBP');
this.value = '';
return;
}
if (file.size > maxSize) {
alert('حجم الصورة يتجاوز الحد المسموح (5 ميجابايت)');
this.value = '';
return;
}
var reader = new FileReader();
reader.onload = function(e) {
preview.innerHTML = '<img src="' + e.target.result + '" alt="معاينة" style="width:100%;height:100%;object-fit:cover;">';
};
reader.readAsDataURL(file);
});
})();
</script>
<?php
declare(strict_types=1);
namespace App\Shared\Services;
use App\Core\App;
final class PhotoUploadService
{
private const MAX_SIZE_BYTES = 5 * 1024 * 1024;
private const MAX_DIMENSION = 8000;
private const OUTPUT_MAX_WIDTH = 800;
private const OUTPUT_MAX_HEIGHT = 800;
private const THUMBNAIL_SIZE = 200;
private const JPEG_QUALITY = 82;
private const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/webp'];
public static function isUploaded(array $file): bool
{
return !empty($file['tmp_name']) && $file['error'] === UPLOAD_ERR_OK;
}
/**
* @return array{valid: bool, errors: string[]}
*/
public static function validate(array $file): array
{
$errors = [];
if (empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK) {
$errors[] = 'لم يتم رفع الصورة بشكل صحيح';
return ['valid' => false, 'errors' => $errors];
}
if ($file['size'] > self::MAX_SIZE_BYTES) {
$errors[] = 'حجم الصورة يتجاوز الحد المسموح (5 ميجابايت)';
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, self::ALLOWED_MIMES, true)) {
$errors[] = 'صيغة الصورة غير مدعومة — يُقبل فقط JPG, PNG, WEBP';
}
$imageInfo = @getimagesize($file['tmp_name']);
if ($imageInfo === false) {
$errors[] = 'الملف ليس صورة صالحة';
} elseif ($imageInfo[0] > self::MAX_DIMENSION || $imageInfo[1] > self::MAX_DIMENSION) {
$errors[] = 'أبعاد الصورة كبيرة جداً (الحد الأقصى 8000×8000)';
}
return ['valid' => empty($errors), 'errors' => $errors];
}
/**
* @return array{path: string, thumbnail_path: string}|null
*/
public static function upload(array $file, string $category, int $entityId): ?array
{
$validation = self::validate($file);
if (!$validation['valid']) {
return null;
}
$uploadDir = self::getUploadDir($category);
$thumbDir = $uploadDir . 'thumbnails/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
if (!is_dir($thumbDir)) {
mkdir($thumbDir, 0755, true);
}
$filename = $category . '_' . $entityId . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.jpg';
$destPath = $uploadDir . $filename;
$thumbPath = $thumbDir . $filename;
if (!self::compress($file['tmp_name'], $destPath)) {
return null;
}
self::createThumbnail($destPath, $thumbPath);
$relativePath = 'uploads/photos/' . $category . '/' . $filename;
$thumbRelative = 'uploads/photos/' . $category . '/thumbnails/' . $filename;
return ['path' => $relativePath, 'thumbnail_path' => $thumbRelative];
}
private static function compress(string $sourcePath, string $destPath): bool
{
$imageInfo = @getimagesize($sourcePath);
if ($imageInfo === false) {
return false;
}
$mime = $imageInfo['mime'];
$srcImage = match ($mime) {
'image/jpeg' => @imagecreatefromjpeg($sourcePath),
'image/png' => @imagecreatefrompng($sourcePath),
'image/webp' => @imagecreatefromwebp($sourcePath),
default => false,
};
if ($srcImage === false) {
return false;
}
$origWidth = imagesx($srcImage);
$origHeight = imagesy($srcImage);
$ratio = min(
self::OUTPUT_MAX_WIDTH / $origWidth,
self::OUTPUT_MAX_HEIGHT / $origHeight,
1.0
);
$newWidth = (int) round($origWidth * $ratio);
$newHeight = (int) round($origHeight * $ratio);
if ($ratio < 1.0) {
$resized = imagecreatetruecolor($newWidth, $newHeight);
imagecopyresampled($resized, $srcImage, 0, 0, 0, 0, $newWidth, $newHeight, $origWidth, $origHeight);
imagedestroy($srcImage);
$srcImage = $resized;
}
$result = imagejpeg($srcImage, $destPath, self::JPEG_QUALITY);
imagedestroy($srcImage);
return $result;
}
private static function createThumbnail(string $sourcePath, string $destPath): bool
{
$imageInfo = @getimagesize($sourcePath);
if ($imageInfo === false) {
return false;
}
$srcImage = @imagecreatefromjpeg($sourcePath);
if ($srcImage === false) {
return false;
}
$origWidth = imagesx($srcImage);
$origHeight = imagesy($srcImage);
$size = min($origWidth, $origHeight);
$offsetX = (int) round(($origWidth - $size) / 2);
$offsetY = (int) round(($origHeight - $size) / 2);
$thumb = imagecreatetruecolor(self::THUMBNAIL_SIZE, self::THUMBNAIL_SIZE);
imagecopyresampled(
$thumb, $srcImage,
0, 0, $offsetX, $offsetY,
self::THUMBNAIL_SIZE, self::THUMBNAIL_SIZE, $size, $size
);
$result = imagejpeg($thumb, $destPath, self::JPEG_QUALITY);
imagedestroy($srcImage);
imagedestroy($thumb);
return $result;
}
private static function getUploadDir(string $category): string
{
return App::getInstance()->basePath() . '/public/uploads/photos/' . $category . '/';
}
}
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE `carnets`
ADD COLUMN `dependent_type` VARCHAR(30) NULL DEFAULT NULL COMMENT 'spouses, children, temporary_members' AFTER `member_id`,
ADD COLUMN `dependent_id` BIGINT UNSIGNED NULL DEFAULT NULL AFTER `dependent_type`,
ADD COLUMN `card_variant` VARCHAR(20) NOT NULL DEFAULT 'main' COMMENT 'main or dependent' AFTER `carnet_type`,
ADD INDEX `idx_carnets_dependent` (`dependent_type`, `dependent_id`);
",
'down' => "
ALTER TABLE `carnets`
DROP INDEX `idx_carnets_dependent`,
DROP COLUMN `card_variant`,
DROP COLUMN `dependent_id`,
DROP COLUMN `dependent_type`;
",
];
<?php
declare(strict_types=1);
return [
'up' => "ALTER TABLE coaches ADD COLUMN coach_type VARCHAR(20) NOT NULL DEFAULT 'independent' AFTER employment_type;
ALTER TABLE coaches ADD INDEX idx_coaches_type (coach_type)",
'down' => "ALTER TABLE coaches DROP INDEX idx_coaches_type;
ALTER TABLE coaches DROP COLUMN coach_type",
];
<?php
declare(strict_types=1);
return [
'up' => "ALTER TABLE sa_coaches ADD COLUMN coach_type VARCHAR(20) NOT NULL DEFAULT 'independent' AFTER employment_type;
ALTER TABLE sa_coaches ADD COLUMN academy_id BIGINT UNSIGNED NULL AFTER coach_type;
ALTER TABLE sa_coaches ADD INDEX idx_sa_coaches_type (coach_type);
ALTER TABLE sa_coaches ADD INDEX idx_sa_coaches_academy (academy_id)",
'down' => "ALTER TABLE sa_coaches DROP INDEX idx_sa_coaches_academy;
ALTER TABLE sa_coaches DROP INDEX idx_sa_coaches_type;
ALTER TABLE sa_coaches DROP COLUMN academy_id;
ALTER TABLE sa_coaches DROP COLUMN coach_type",
];
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