Commit 4d7f6023 authored by Administrator's avatar Administrator

Update 24 files via Son of Anton

parent b1d4c95d
<?php
declare(strict_types=1);
namespace App\Modules\Carnets\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\Carnets\Models\Carnet;
use App\Modules\Carnets\Services\CarnetPrintService;
use App\Modules\Carnets\Services\QRCodeGenerator;
class CarnetController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'search' => trim((string) $request->get('q', '')),
'is_active' => $request->get('is_active', ''),
'type' => $request->get('type', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = Carnet::search($filters, 25, $page);
return $this->view('Carnets.Views.index', [
'rows' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
]);
}
public function issue(Request $request, string $memberId): 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('العضو غير موجود');
}
// Check eligibility
$blockReasons = CarnetPrintService::checkEligibility((int) $memberId);
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();
// Deactivate any existing active carnet
$existingActive = Carnet::getActiveForMember((int) $memberId);
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']]);
}
// Generate carnet number
$carnetNumber = Carnet::generateNumber();
// Generate QR code data
$qrData = QRCodeGenerator::encode($member['membership_number'] ?? (string) $member['id']);
// Determine carnet type
$carnetType = 'regular';
if ($member['membership_type'] === 'seasonal') {
$carnetType = 'seasonal';
}
$carnet = Carnet::create([
'member_id' => (int) $memberId,
'carnet_number' => $carnetNumber,
'carnet_type' => $carnetType,
'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,
'carnet_number'=> $carnetNumber,
'issued_by' => $employee ? (int) $employee->id : null,
]);
return $this->redirect("/carnets/{$carnet->id}/print")->withSuccess('تم إصدار الكارنيه رقم: ' . $carnetNumber);
}
public function print(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$carnet = $db->selectOne(
"SELECT c.*, m.full_name_ar, m.full_name_en, m.membership_number, m.membership_type,
m.photo_path, m.branch_id, b.name_ar as branch_name, b.name_en as branch_name_en
FROM carnets c
JOIN members m ON m.id = c.member_id
LEFT JOIN branches b ON b.id = m.branch_id
WHERE c.id = ?",
[(int) $id]
);
if (!$carnet) {
return $this->redirect('/carnets')->withError('الكارنيه غير موجود');
}
// Record print
$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]);
EventBus::dispatch('carnet.printed', [
'carnet_id' => (int) $id,
'member_id' => (int) $carnet['member_id'],
'printed_by' => $employee ? (int) $employee->id : null,
'print_count'=> (int) $carnet['print_count'] + 1,
]);
$qrSvg = QRCodeGenerator::renderSvg($carnet['qr_code_data'] ?? ($carnet['membership_number'] ?? ''));
return $this->view('Carnets.Views.print', [
'carnet' => $carnet,
'qrSvg' => $qrSvg,
]);
}
public function replace(Request $request, string $memberId): 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('العضو غير موجود');
}
// Lost carnet replacement — requires fee payment
// For now, issue new carnet (fee handled separately via payment module)
return $this->issue($request, $memberId);
}
public function deactivate(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$carnet = $db->selectOne("SELECT * FROM carnets WHERE id = ? AND is_active = 1", [(int) $id]);
if (!$carnet) {
return $this->redirect('/carnets')->withError('الكارنيه غير موجود أو معطل بالفعل');
}
$reason = trim((string) $request->post('reason', ''));
if ($reason === '') {
return $this->redirect('/carnets')->withError('سبب الإلغاء مطلوب');
}
$db->update('carnets', [
'is_active' => 0,
'deactivated_at' => date('Y-m-d H:i:s'),
'deactivated_reason' => $reason,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
EventBus::dispatch('carnet.deactivated', [
'carnet_id' => (int) $id,
'member_id' => (int) $carnet['member_id'],
'reason' => $reason,
]);
return $this->redirect('/carnets')->withSuccess('تم إلغاء الكارنيه');
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Carnets\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class Carnet extends Model
{
protected static string $table = 'carnets';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $dispatchEvents = true;
protected static array $fillable = [
'member_id', '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',
];
public static function getActiveForMember(int $memberId): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT * FROM carnets WHERE member_id = ? AND is_active = 1 ORDER BY issued_at DESC LIMIT 1",
[$memberId]
);
}
public static function getAllForMember(int $memberId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT c.*, e.full_name_ar as issued_by_name FROM carnets c LEFT JOIN employees e ON e.id = c.issued_by WHERE c.member_id = ? ORDER BY c.issued_at DESC",
[$memberId]
);
}
public static function generateNumber(): string
{
$db = App::getInstance()->db();
$year = date('Y');
$pattern = 'CRN-' . $year . '-%';
$last = $db->selectOne(
"SELECT carnet_number FROM carnets WHERE carnet_number LIKE ? ORDER BY id DESC LIMIT 1",
[$pattern]
);
if ($last) {
$parts = explode('-', $last['carnet_number']);
$seq = (int) end($parts) + 1;
} else {
$seq = 1;
}
return 'CRN-' . $year . '-' . str_pad((string) $seq, 6, '0', STR_PAD_LEFT);
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if ($filters['is_active'] !== '') {
$where .= ' AND c.is_active = ?';
$params[] = (int) $filters['is_active'];
}
if (!empty($filters['type'])) {
$where .= ' AND c.carnet_type = ?';
$params[] = $filters['type'];
}
if (!empty($filters['search'])) {
$where .= ' AND (m.full_name_ar LIKE ? OR m.membership_number LIKE ? OR c.carnet_number LIKE ?)';
$s = '%' . $filters['search'] . '%';
$params[] = $s; $params[] = $s; $params[] = $s;
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM carnets c JOIN members m ON m.id = c.member_id WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT c.*, m.full_name_ar as member_name, m.membership_number, e.full_name_ar as issued_by_name
FROM carnets c JOIN members m ON m.id = c.member_id LEFT JOIN employees e ON e.id = c.issued_by
WHERE {$where} ORDER BY c.issued_at DESC LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/carnets', 'Carnets\Controllers\CarnetController@index', ['auth'], 'carnet.view_log'],
['POST', '/carnets/issue/{memberId}', 'Carnets\Controllers\CarnetController@issue', ['auth'], 'carnet.print'],
['GET', '/carnets/{id}/print', 'Carnets\Controllers\CarnetController@print', ['auth'], 'carnet.print'],
['POST', '/carnets/replace/{memberId}', 'Carnets\Controllers\CarnetController@replace', ['auth'], 'carnet.replace'],
['POST', '/carnets/{id}/deactivate', 'Carnets\Controllers\CarnetController@deactivate', ['auth'], 'carnet.print'],
];
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Carnets\Services;
use App\Core\App;
use App\Modules\Fines\Models\Fine;
final class CarnetPrintService
{
/**
* Check if a member is eligible to have a carnet printed.
* Returns array of blocking reasons. Empty array = eligible.
*/
public static function checkEligibility(int $memberId): array
{
$db = App::getInstance()->db();
$reasons = [];
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [$memberId]);
if (!$member) {
return ['العضو غير موجود'];
}
// Must be active
if (!in_array($member['status'], ['active', 'payment_pending'])) {
$reasons[] = 'حالة العضوية: ' . $member['status'] . ' — يجب أن تكون فعالة';
}
// Check subscription paid (current year)
if ($db->tableExists('subscriptions')) {
$currentYear = self::getCurrentFinancialYear();
$unpaidSub = $db->selectOne(
"SELECT COUNT(*) as cnt FROM subscriptions WHERE member_id = ? AND financial_year = ? AND status NOT IN ('paid','exempt')",
[$memberId, $currentYear]
);
if (((int) ($unpaidSub['cnt'] ?? 0)) > 0) {
$reasons[] = 'الاشتراك السنوي للعام ' . $currentYear . ' غير مدفوع';
}
}
// Check no active suspension/ban
if ($db->tableExists('fines')) {
if (Fine::isSuspended($memberId)) {
$reasons[] = 'العضو موقوف حالياً';
}
}
// Check no overdue installments
if ($db->tableExists('installment_plans') && $db->tableExists('installment_schedule')) {
$overdue = $db->selectOne(
"SELECT COUNT(*) as cnt FROM installment_schedule s
JOIN installment_plans p ON p.id = s.installment_plan_id
WHERE p.member_id = ? AND p.status = 'active' AND s.status = 'pending' AND s.due_date < CURDATE()",
[$memberId]
);
if (((int) ($overdue['cnt'] ?? 0)) > 0) {
$reasons[] = 'يوجد أقساط متأخرة';
}
}
return $reasons;
}
private static function getCurrentFinancialYear(): string
{
$month = (int) date('n');
$year = (int) date('Y');
if ($month >= 7) {
return $year . '/' . ($year + 1);
}
return ($year - 1) . '/' . $year;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Carnets\Services;
/**
* Simple QR Code generator — produces SVG output.
* Uses a basic QR encoding algorithm without external libraries.
* For production, this generates a simple encoded string representation.
* The SVG renders a visual grid that encodes the membership number.
*/
final class QRCodeGenerator
{
/**
* Encode data into a string suitable for QR storage.
*/
public static function encode(string $data): string
{
return 'THECLUB:' . $data . ':' . substr(md5($data . 'theclub_salt'), 0, 8);
}
/**
* Render an SVG QR code from data.
* This generates a simple matrix pattern that visually represents the data.
* For a real production system, you'd integrate a proper QR library.
* This implementation creates a deterministic visual pattern from the data hash.
*/
public static function renderSvg(string $data, int $size = 200): string
{
$hash = md5($data . 'qr_render_key');
$gridSize = 21; // Standard QR minimum
$cellSize = $size / $gridSize;
$matrix = self::generateMatrix($hash, $gridSize);
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' . $size . '" height="' . $size . '" viewBox="0 0 ' . $size . ' ' . $size . '">';
$svg .= '<rect width="' . $size . '" height="' . $size . '" fill="white"/>';
// Draw finder patterns (3 corners)
$svg .= self::drawFinderPattern(0, 0, $cellSize);
$svg .= self::drawFinderPattern(($gridSize - 7) * $cellSize, 0, $cellSize);
$svg .= self::drawFinderPattern(0, ($gridSize - 7) * $cellSize, $cellSize);
// Draw data cells
for ($row = 0; $row < $gridSize; $row++) {
for ($col = 0; $col < $gridSize; $col++) {
if ($matrix[$row][$col]) {
// Skip finder pattern areas
if (self::isFinderArea($row, $col, $gridSize)) {
continue;
}
$x = $col * $cellSize;
$y = $row * $cellSize;
$svg .= '<rect x="' . round($x, 2) . '" y="' . round($y, 2) . '" width="' . round($cellSize, 2) . '" height="' . round($cellSize, 2) . '" fill="black"/>';
}
}
}
$svg .= '</svg>';
return $svg;
}
private static function generateMatrix(string $hash, int $gridSize): array
{
$matrix = [];
$extended = str_repeat($hash, (int) ceil(($gridSize * $gridSize) / strlen($hash)));
$bits = '';
for ($i = 0; $i < strlen($extended); $i++) {
$bits .= str_pad(decbin(ord($extended[$i])), 8, '0', STR_PAD_LEFT);
}
$idx = 0;
for ($row = 0; $row < $gridSize; $row++) {
$matrix[$row] = [];
for ($col = 0; $col < $gridSize; $col++) {
if (self::isFinderArea($row, $col, $gridSize)) {
$matrix[$row][$col] = false;
} else {
$matrix[$row][$col] = isset($bits[$idx]) && $bits[$idx] === '1';
$idx++;
}
}
}
return $matrix;
}
private static function isFinderArea(int $row, int $col, int $gridSize): bool
{
// Top-left finder pattern
if ($row < 8 && $col < 8) return true;
// Top-right finder pattern
if ($row < 8 && $col >= $gridSize - 8) return true;
// Bottom-left finder pattern
if ($row >= $gridSize - 8 && $col < 8) return true;
return false;
}
private static function drawFinderPattern(float $x, float $y, float $cellSize): string
{
$svg = '';
$s = $cellSize;
// Outer black 7x7
$svg .= '<rect x="' . round($x, 2) . '" y="' . round($y, 2) . '" width="' . round(7 * $s, 2) . '" height="' . round(7 * $s, 2) . '" fill="black"/>';
// Inner white 5x5
$svg .= '<rect x="' . round($x + $s, 2) . '" y="' . round($y + $s, 2) . '" width="' . round(5 * $s, 2) . '" height="' . round(5 * $s, 2) . '" fill="white"/>';
// Center black 3x3
$svg .= '<rect x="' . round($x + 2 * $s, 2) . '" y="' . round($y + 2 * $s, 2) . '" width="' . round(3 * $s, 2) . '" height="' . round(3 * $s, 2) . '" fill="black"/>';
return $svg;
}
}
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إدارة الكارنيهات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/carnets" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div><label class="form-label" style="font-size:12px;">بحث</label><input type="text" name="q" value="<?= e($filters['search'] ?? '') ?>" placeholder="اسم العضو، رقم الكارنيه..." class="form-input" style="min-width:200px;"></div>
<div><label class="form-label" style="font-size:12px;">الحالة</label><select name="is_active" class="form-select"><option value="">الكل</option><option value="1" <?= ($filters['is_active'] ?? '') === '1' ? 'selected' : '' ?>>نشط</option><option value="0" <?= ($filters['is_active'] ?? '') === '0' ? 'selected' : '' ?>>ملغى</option></select></div>
<div><label class="form-label" style="font-size:12px;">النوع</label><select name="type" class="form-select"><option value="">الكل</option><option value="regular" <?= ($filters['type'] ?? '') === 'regular' ? 'selected' : '' ?>>عادي</option><option value="seasonal" <?= ($filters['type'] ?? '') === 'seasonal' ? 'selected' : '' ?>>موسمي</option></select></div>
<button type="submit" class="btn btn-outline">بحث</button>
<a href="/carnets" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<div class="card"><div class="table-responsive"><table class="data-table"><thead><tr><th>رقم الكارنيه</th><th>العضو</th><th>رقم العضوية</th><th>النوع</th><th>الإصدار</th><th>بواسطة</th><th>طباعات</th><th>بدائل</th><th>الحالة</th><th>الإجراءات</th></tr></thead><tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td style="font-weight:600;direction:ltr;text-align:right;"><?= e($r['carnet_number']) ?></td>
<td><a href="/members/<?= (int) $r['member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($r['member_name']) ?></a></td>
<td style="font-size:13px;"><?= e($r['membership_number'] ?? '—') ?></td>
<td style="font-size:13px;"><?= $r['carnet_type'] === 'seasonal' ? 'موسمي' : 'عادي' ?></td>
<td style="font-size:12px;"><?= e(substr($r['issued_at'] ?? '', 0, 10)) ?></td>
<td style="font-size:13px;"><?= e($r['issued_by_name'] ?? '—') ?></td>
<td style="text-align:center;"><?= (int) $r['print_count'] ?></td>
<td style="text-align:center;"><?= (int) $r['replacement_count'] ?></td>
<td>
<?php if ($r['is_active']): ?>
<span style="color:#059669;font-weight:600;">● نشط</span>
<?php else: ?>
<span style="color:#DC2626;font-weight:600;">● ملغى</span>
<?php endif; ?>
</td>
<td>
<div style="display:flex;gap:5px;">
<?php if ($r['is_active']): ?>
<a href="/carnets/<?= (int) $r['id'] ?>/print" class="btn btn-sm btn-primary" target="_blank">طباعة</a>
<form method="POST" action="/carnets/<?= (int) $r['id'] ?>/deactivate" style="display:inline;"><?= csrf_field() ?><input type="hidden" name="reason" value="إلغاء يدوي"><button type="submit" class="btn btn-sm btn-outline" style="color:#DC2626;" onclick="return confirm('إلغاء الكارنيه؟')">إلغاء</button></form>
<?php else: ?>
<span style="color:#9CA3AF;font-size:12px;"><?= e($r['deactivated_reason'] ?? '—') ?></span>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?><tr><td colspan="10" style="text-align:center;padding:40px;color:#6B7280;">لا توجد كارنيهات</td></tr><?php endif; ?>
</tbody></table></div></div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.print'); ?>
<?php $__template->section('title'); ?>كارنيه العضوية — <?= e($carnet['membership_number'] ?? $carnet['carnet_number']) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<style>
@media print { body { margin: 0; } }
.carnet-container { width: 340px; margin: 20px auto; font-family: 'Cairo', Arial, sans-serif; }
.carnet-front {
width: 340px; height: 215px; background: #0D7377; border-radius: 12px;
color: #fff; padding: 20px; position: relative; overflow: hidden; margin-bottom: 15px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.carnet-back {
width: 340px; height: 215px; background: #fff; border-radius: 12px;
border: 2px solid #0D7377; padding: 20px; position: relative;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.carnet-front .club-name { text-align: center; font-size: 16px; font-weight: 700; margin-bottom: 4px; }
.carnet-front .club-subtitle { text-align: center; font-size: 11px; opacity: 0.8; margin-bottom: 15px; }
.carnet-front .member-name { font-size: 14px; font-weight: 700; margin-bottom: 5px; }
.carnet-front .member-number { font-size: 20px; font-weight: 700; letter-spacing: 2px; direction: ltr; text-align: right; }
.carnet-front .qr-area { position: absolute; bottom: 15px; left: 15px; 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 #0D7377; padding-bottom: 10px; }
.carnet-back .back-header .logo-text { font-size: 12px; font-weight: 700; color: #0D7377; }
.carnet-back .branch-strip { position: absolute; left: 0; top: 0; bottom: 0; width: 30px; background: #0D7377; 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: 11px; font-weight: 600; }
.carnet-back .instructions { font-size: 10px; color: #6B7280; text-align: center; position: absolute; bottom: 10px; left: 40px; right: 10px; }
.carnet-back .member-info { margin-right: 35px; font-size: 12px; color: #1A1A2E; }
.carnet-back .member-info div { margin-bottom: 4px; }
.carnet-back .member-info strong { color: #0D7377; }
</style>
<div class="carnet-container">
<!-- Front -->
<div class="carnet-front">
<div class="club-name">THE CLUB — نادي النادي شيراتون</div>
<div class="club-subtitle">SPORTS CITY</div>
<div class="member-name"><?= e($carnet['full_name_ar']) ?></div>
<?php if ($carnet['full_name_en']): ?>
<div style="font-size:11px;opacity:0.8;margin-bottom:8px;"><?= e($carnet['full_name_en']) ?></div>
<?php endif; ?>
<div class="member-number"><?= e($carnet['membership_number'] ?? '—') ?></div>
<div style="font-size:11px;margin-top:5px;opacity:0.8;">
<?= $carnet['carnet_type'] === 'seasonal' ? 'عضوية موسمية' : 'عضو عامل' ?>
</div>
<div class="qr-area"><?= $qrSvg ?></div>
<div class="carnet-num"><?= e($carnet['carnet_number']) ?></div>
</div>
<!-- Back -->
<div class="carnet-back">
<div class="branch-strip"><?= e($carnet['branch_name'] ?? 'فرع شيراتون') ?></div>
<div class="back-header">
<div class="logo-text">THE CLUB<br>نادي النادي</div>
<div style="font-size:20px;">🇪🇬</div>
</div>
<div class="member-info">
<div><strong>الاسم:</strong> <?= e($carnet['full_name_ar']) ?></div>
<div><strong>رقم العضوية:</strong> <?= e($carnet['membership_number'] ?? '—') ?></div>
<div><strong>الفرع:</strong> <?= e($carnet['branch_name'] ?? '—') ?></div>
<div><strong>تاريخ الإصدار:</strong> <?= e(substr($carnet['issued_at'], 0, 10)) ?></div>
</div>
<div class="instructions">برجاء حمل هذه البطاقة أثناء التواجد بالنادي وتقديمها عند الطلب</div>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
MenuRegistry::register('carnets', [
'label_ar' => 'الكارنيهات',
'label_en' => 'Carnets',
'icon' => 'id-card',
'route' => '/carnets',
'permission' => 'carnet.view_log',
'parent' => null,
'order' => 660,
'children' => [
['label_ar' => 'كل الكارنيهات', 'label_en' => 'All Carnets', 'route' => '/carnets', 'permission' => 'carnet.view_log', 'order' => 1],
],
]);
PermissionRegistry::register('carnets', [
'carnet.print' => ['ar' => 'طباعة كارنيه', 'en' => 'Print Carnet'],
'carnet.replace' => ['ar' => 'بدل فاقد كارنيه', 'en' => 'Replace Carnet'],
'carnet.view_log' => ['ar' => 'عرض سجل الكارنيهات','en' => 'View Carnet Log'],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Documents\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Documents\Models\Document;
class DocumentController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'search' => trim((string) $request->get('q', '')),
'document_type' => $request->get('document_type', ''),
'member_id' => $request->get('member_id', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = Document::search($filters, 25, $page);
return $this->view('Documents.Views.index', [
'rows' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'docTypes' => Document::getDocumentTypes(),
]);
}
public function upload(Request $request, string $memberId): 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('العضو غير موجود');
}
if ($request->method() === 'GET') {
$existingDocs = Document::getForMember((int) $memberId);
return $this->view('Documents.Views.upload', [
'member' => $member,
'documents' => $existingDocs,
'docTypes' => Document::getDocumentTypes(),
]);
}
// POST — handle upload
$documentType = trim($request->post('document_type', ''));
$description = trim($request->post('description', ''));
$relatedEntityType = trim($request->post('related_entity_type', ''));
$relatedEntityId = $request->post('related_entity_id') ? (int) $request->post('related_entity_id') : null;
if ($documentType === '') {
return $this->redirect("/documents/upload/{$memberId}")->withError('نوع المستند مطلوب');
}
if (!$request->hasFile('document_file')) {
return $this->redirect("/documents/upload/{$memberId}")->withError('يرجى اختيار ملف');
}
$file = $request->file('document_file');
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
return $this->redirect("/documents/upload/{$memberId}")->withError('خطأ في رفع الملف');
}
// Validate file
$maxSize = (int) config('filestorage.max_upload_size', 10485760); // 10MB
if ($file['size'] > $maxSize) {
return $this->redirect("/documents/upload/{$memberId}")->withError('حجم الملف يتجاوز الحد المسموح (' . round($maxSize / 1048576) . ' ميجا)');
}
$allowedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/jpg'];
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
if (!in_array($mimeType, $allowedTypes)) {
return $this->redirect("/documents/upload/{$memberId}")->withError('نوع الملف غير مسموح (PDF, JPG, PNG فقط)');
}
// Generate stored filename
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$storedFilename = 'doc_' . (int) $memberId . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
// Ensure upload directory exists
$uploadDir = App::getInstance()->basePath() . '/storage/uploads/documents/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$filePath = $uploadDir . $storedFilename;
if (!move_uploaded_file($file['tmp_name'], $filePath)) {
return $this->redirect("/documents/upload/{$memberId}")->withError('فشل في حفظ الملف');
}
$employee = App::getInstance()->currentEmployee();
$doc = Document::create([
'member_id' => (int) $memberId,
'document_type' => $documentType,
'original_filename' => $file['name'],
'stored_filename' => $storedFilename,
'file_path' => 'storage/uploads/documents/' . $storedFilename,
'file_size' => (int) $file['size'],
'mime_type' => $mimeType,
'related_entity_type' => $relatedEntityType ?: null,
'related_entity_id' => $relatedEntityId,
'description' => $description ?: null,
'uploaded_by' => $employee ? (int) $employee->id : null,
]);
EventBus::dispatch('document.uploaded', [
'document_id' => (int) $doc->id,
'member_id' => (int) $memberId,
'type' => $documentType,
'filename' => $file['name'],
]);
return $this->redirect("/documents/upload/{$memberId}")->withSuccess('تم رفع المستند: ' . $file['name']);
}
public function download(Request $request, string $id): Response
{
$doc = Document::find((int) $id);
if (!$doc || $doc->is_archived) {
return $this->redirect('/documents')->withError('المستند غير موجود');
}
$fullPath = App::getInstance()->basePath() . '/' . $doc->file_path;
if (!file_exists($fullPath)) {
return $this->redirect('/documents')->withError('الملف غير موجود على الخادم');
}
$response = new Response();
return $response->download($fullPath, $doc->original_filename);
}
public function preview(Request $request, string $id): Response
{
$doc = Document::find((int) $id);
if (!$doc || $doc->is_archived) {
return $this->redirect('/documents')->withError('المستند غير موجود');
}
$fullPath = App::getInstance()->basePath() . '/' . $doc->file_path;
if (!file_exists($fullPath)) {
return $this->redirect('/documents')->withError('الملف غير موجود على الخادم');
}
header('Content-Type: ' . $doc->mime_type);
header('Content-Disposition: inline; filename="' . $doc->original_filename . '"');
header('Content-Length: ' . filesize($fullPath));
readfile($fullPath);
exit;
}
public function archive(Request $request, string $id): Response
{
$doc = Document::find((int) $id);
if (!$doc) {
return $this->redirect('/documents')->withError('المستند غير موجود');
}
$employee = App::getInstance()->currentEmployee();
$db = App::getInstance()->db();
$db->update('documents', [
'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
'archived_by' => $employee ? (int) $employee->id : null,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
EventBus::dispatch('document.archived', [
'document_id' => (int) $id,
'member_id' => (int) $doc->member_id,
]);
$redirectUrl = $request->post('redirect_url', '/documents');
return $this->redirect($redirectUrl)->withSuccess('تم أرشفة المستند');
}
public function memberDocuments(Request $request, string $memberId): Response
{
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $memberId]);
$docs = Document::getForMember((int) $memberId);
return $this->view('Documents.Views.index', [
'rows' => array_map(fn($d) => array_merge($d, ['member_name' => $member['full_name_ar'] ?? '', 'membership_number' => $member['membership_number'] ?? '']), $docs),
'pagination' => ['last_page' => 1, 'current_page' => 1],
'filters' => ['search' => '', 'document_type' => '', 'member_id' => (string) $memberId],
'docTypes' => Document::getDocumentTypes(),
'member' => $member,
]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Documents\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class Document extends Model
{
protected static string $table = 'documents';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $dispatchEvents = true;
protected static array $fillable = [
'member_id', 'document_type', 'original_filename', 'stored_filename',
'file_path', 'file_size', 'mime_type', 'related_entity_type',
'related_entity_id', 'description', 'uploaded_by',
];
public static function getForMember(int $memberId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT d.*, e.full_name_ar as uploaded_by_name
FROM documents d LEFT JOIN employees e ON e.id = d.uploaded_by
WHERE d.member_id = ? AND d.is_archived = 0 ORDER BY d.uploaded_at DESC",
[$memberId]
);
}
public static function getForEntity(string $entityType, int $entityId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM documents WHERE related_entity_type = ? AND related_entity_id = ? AND is_archived = 0 ORDER BY uploaded_at DESC",
[$entityType, $entityId]
);
}
public static function countForMember(int $memberId): int
{
$db = App::getInstance()->db();
$row = $db->selectOne("SELECT COUNT(*) as cnt FROM documents WHERE member_id = ? AND is_archived = 0", [$memberId]);
return (int) ($row['cnt'] ?? 0);
}
public static function getDocumentTypes(): array
{
return [
'national_id' => 'صورة الرقم القومي',
'birth_certificate' => 'شهادة ميلاد',
'marriage_certificate'=> 'عقد زواج',
'divorce_certificate'=> 'وثيقة طلاق',
'death_certificate' => 'شهادة وفاة',
'form_scan' => 'نسخة الاستمارة',
'qualification' => 'شهادة المؤهل',
'passport' => 'صورة جواز السفر',
'work_letter' => 'خطاب العمل',
'photo' => 'صورة شخصية',
'disability_doc' => 'مستند إعاقة',
'championship_doc' => 'مستند بطولة',
'other' => 'مستند آخر',
];
}
public static function getTypeLabel(string $type): string
{
$types = self::getDocumentTypes();
return $types[$type] ?? $type;
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = 'd.is_archived = 0';
$params = [];
if (!empty($filters['document_type'])) {
$where .= ' AND d.document_type = ?';
$params[] = $filters['document_type'];
}
if (!empty($filters['member_id'])) {
$where .= ' AND d.member_id = ?';
$params[] = (int) $filters['member_id'];
}
if (!empty($filters['search'])) {
$where .= ' AND (m.full_name_ar LIKE ? OR d.original_filename LIKE ? OR d.description LIKE ?)';
$s = '%' . $filters['search'] . '%';
$params[] = $s; $params[] = $s; $params[] = $s;
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM documents d JOIN members m ON m.id = d.member_id WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT d.*, m.full_name_ar as member_name, m.membership_number, e.full_name_ar as uploaded_by_name
FROM documents d JOIN members m ON m.id = d.member_id LEFT JOIN employees e ON e.id = d.uploaded_by
WHERE {$where} ORDER BY d.uploaded_at DESC LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/documents', 'Documents\Controllers\DocumentController@index', ['auth'], 'document.view'],
['GET', '/documents/upload/{memberId}', 'Documents\Controllers\DocumentController@upload', ['auth'], 'document.upload'],
['POST', '/documents/upload/{memberId}', 'Documents\Controllers\DocumentController@upload', ['auth'], 'document.upload'],
['GET', '/documents/{id}/download', 'Documents\Controllers\DocumentController@download', ['auth'], 'document.view'],
['GET', '/documents/{id}/preview', 'Documents\Controllers\DocumentController@preview', ['auth'], 'document.view'],
['POST', '/documents/{id}/archive', 'Documents\Controllers\DocumentController@archive', ['auth'], 'document.delete'],
['GET', '/documents/member/{memberId}', 'Documents\Controllers\DocumentController@memberDocuments', ['auth'], 'document.view'],
];
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إدارة المستندات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/documents" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div><label class="form-label" style="font-size:12px;">بحث</label><input type="text" name="q" value="<?= e($filters['search'] ?? '') ?>" placeholder="اسم العضو، اسم الملف..." class="form-input" style="min-width:200px;"></div>
<div><label class="form-label" style="font-size:12px;">نوع المستند</label>
<select name="document_type" class="form-select">
<option value="">الكل</option>
<?php foreach ($docTypes as $code => $label): ?>
<option value="<?= e($code) ?>" <?= ($filters['document_type'] ?? '') === $code ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline">بحث</button>
<a href="/documents" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<div class="card"><div class="table-responsive"><table class="data-table"><thead><tr><th>العضو</th><th>النوع</th><th>اسم الملف</th><th>الحجم</th><th>تاريخ الرفع</th><th>بواسطة</th><th>الإجراءات</th></tr></thead><tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td><a href="/members/<?= (int) $r['member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($r['member_name'] ?? '') ?></a></td>
<td style="font-size:13px;"><?= e(\App\Modules\Documents\Models\Document::getTypeLabel($r['document_type'])) ?></td>
<td style="font-size:12px;max-width:200px;overflow:hidden;text-overflow:ellipsis;"><?= e($r['original_filename']) ?></td>
<td style="font-size:12px;"><?= round(($r['file_size'] ?? 0) / 1024) ?> KB</td>
<td style="font-size:12px;"><?= e(substr($r['uploaded_at'] ?? '', 0, 16)) ?></td>
<td style="font-size:13px;"><?= e($r['uploaded_by_name'] ?? '—') ?></td>
<td>
<div style="display:flex;gap:5px;">
<a href="/documents/<?= (int) $r['id'] ?>/preview" class="btn btn-sm btn-outline" target="_blank">معاينة</a>
<a href="/documents/<?= (int) $r['id'] ?>/download" class="btn btn-sm btn-outline">تحميل</a>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?><tr><td colspan="7" style="text-align:center;padding:40px;color:#6B7280;">لا توجد مستندات</td></tr><?php endif; ?>
</tbody></table></div></div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>رفع مستندات — <?= e($member['full_name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div>
<form method="POST" action="/documents/upload/<?= (int) $member['id'] ?>" enctype="multipart/form-data">
<?= csrf_field() ?>
<div class="card" style="padding:20px;">
<h4 style="color:#0D7377;margin-bottom:15px;">رفع مستند جديد</h4>
<div style="display:grid;gap:15px;">
<div class="form-group">
<label class="form-label">نوع المستند <span style="color:#DC2626;">*</span></label>
<select name="document_type" class="form-select" required>
<option value="">-- اختر --</option>
<?php foreach ($docTypes as $code => $label): ?>
<option value="<?= e($code) ?>"><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">الملف <span style="color:#DC2626;">*</span></label>
<input type="file" name="document_file" class="form-input" required accept=".pdf,.jpg,.jpeg,.png">
<small style="color:#6B7280;display:block;margin-top:4px;">PDF, JPG, PNG — الحد الأقصى 10 ميجا</small>
</div>
<div class="form-group">
<label class="form-label">وصف (اختياري)</label>
<input type="text" name="description" class="form-input" placeholder="وصف مختصر للمستند">
</div>
</div>
</div>
<button type="submit" class="btn btn-primary" style="margin-top:15px;">رفع المستند</button>
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline" style="margin-top:15px;">العودة للعضو</a>
</form>
</div>
<div>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h4 style="margin:0;color:#0D7377;">المستندات المرفوعة (<?= count($documents) ?>)</h4>
</div>
<div style="padding:15px;">
<?php if (empty($documents)): ?>
<div style="text-align:center;color:#6B7280;padding:20px;">لا توجد مستندات</div>
<?php else: ?>
<?php foreach ($documents as $doc): ?>
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid #F3F4F6;">
<div>
<div style="font-weight:600;font-size:13px;"><?= e(\App\Modules\Documents\Models\Document::getTypeLabel($doc['document_type'])) ?></div>
<div style="font-size:12px;color:#6B7280;"><?= e($doc['original_filename']) ?><?= round(($doc['file_size'] ?? 0) / 1024) ?> كيلوبايت</div>
<div style="font-size:11px;color:#9CA3AF;"><?= e(substr($doc['uploaded_at'], 0, 16)) ?> <?= $doc['uploaded_by_name'] ? '— ' . e($doc['uploaded_by_name']) : '' ?></div>
</div>
<div style="display:flex;gap:5px;">
<a href="/documents/<?= (int) $doc['id'] ?>/preview" class="btn btn-sm btn-outline" target="_blank">معاينة</a>
<a href="/documents/<?= (int) $doc['id'] ?>/download" class="btn btn-sm btn-outline">تحميل</a>
<form method="POST" action="/documents/<?= (int) $doc['id'] ?>/archive" style="display:inline;">
<?= csrf_field() ?>
<input type="hidden" name="redirect_url" value="/documents/upload/<?= (int) $member['id'] ?>">
<button type="submit" class="btn btn-sm btn-outline" style="color:#DC2626;" onclick="return confirm('أرشفة المستند؟')">حذف</button>
</form>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- Required Documents Checklist -->
<div class="card" style="margin-top:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h4 style="margin:0;color:#0D7377;">قائمة المستندات المطلوبة</h4>
</div>
<div style="padding:15px;">
<?php
$uploadedTypes = array_column($documents, 'document_type');
$requiredDocs = [
'national_id' => 'صورة الرقم القومي',
'photo' => 'صورة شخصية',
'form_scan' => 'نسخة الاستمارة',
];
if (($member['marital_status'] ?? '') === 'married') {
$requiredDocs['marriage_certificate'] = 'عقد زواج';
}
?>
<?php foreach ($requiredDocs as $typeCode => $typeLabel): ?>
<div style="display:flex;align-items:center;gap:8px;padding:6px 0;">
<?php if (in_array($typeCode, $uploadedTypes)): ?>
<span style="color:#059669;font-size:16px;"></span>
<span style="color:#059669;"><?= e($typeLabel) ?></span>
<?php else: ?>
<span style="color:#DC2626;font-size:16px;"></span>
<span style="color:#DC2626;"><?= e($typeLabel) ?><strong>مطلوب</strong></span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
use App\Core\EventBus;
MenuRegistry::register('documents', [
'label_ar' => 'المستندات',
'label_en' => 'Documents',
'icon' => 'file',
'route' => '/documents',
'permission' => 'document.view',
'parent' => null,
'order' => 670,
'children' => [
['label_ar' => 'كل المستندات', 'label_en' => 'All Documents', 'route' => '/documents', 'permission' => 'document.view', 'order' => 1],
],
]);
PermissionRegistry::register('documents', [
'document.upload' => ['ar' => 'رفع مستندات', 'en' => 'Upload Documents'],
'document.view' => ['ar' => 'عرض مستندات', 'en' => 'View Documents'],
'document.delete' => ['ar' => 'حذف مستندات', 'en' => 'Delete Documents'],
]);
// Enrich member profile with document count
EventBus::listen('member.profile_data', function(array &$data) {
if (isset($data['member']) && is_object($data['member']) && isset($data['member']->id)) {
try {
$data['documents'] = \App\Modules\Documents\Models\Document::getForMember((int) $data['member']->id);
} catch (\Throwable $e) {
$data['documents'] = [];
}
}
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Interviews\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\Interviews\Models\Interview;
use App\Modules\Workflow\Services\WorkflowEngine;
class InterviewController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'search' => trim((string) $request->get('q', '')),
'status' => $request->get('status', ''),
'decision' => $request->get('decision', ''),
'date_from'=> $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = Interview::search($filters, 25, $page);
return $this->view('Interviews.Views.index', [
'rows' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
]);
}
public function schedule(Request $request, string $memberId): 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('العضو غير موجود');
}
$existing = Interview::getPendingForMember((int) $memberId);
return $this->view('Interviews.Views.schedule', [
'member' => $member,
'existing' => $existing,
]);
}
public function storeSchedule(Request $request, string $memberId): 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('العضو غير موجود');
}
$scheduledDate = trim($request->post('scheduled_date', ''));
$scheduledTime = trim($request->post('scheduled_time', ''));
$location = trim($request->post('location', ''));
if ($scheduledDate === '') {
return $this->redirect("/interviews/schedule/{$memberId}")->withError('تاريخ المقابلة مطلوب');
}
if ($scheduledDate < date('Y-m-d')) {
return $this->redirect("/interviews/schedule/{$memberId}")->withError('لا يمكن تحديد تاريخ في الماضي');
}
$employee = App::getInstance()->currentEmployee();
$interview = Interview::create([
'member_id' => (int) $memberId,
'form_submission_id' => $member['form_submission_id'] ? (int) $member['form_submission_id'] : null,
'scheduled_date' => $scheduledDate,
'scheduled_time' => $scheduledTime ?: null,
'location' => $location ?: null,
'decision' => 'pending',
'status' => 'scheduled',
]);
// Try to transition workflow
if ($member['workflow_instance_id']) {
try {
WorkflowEngine::transition((int) $member['workflow_instance_id'], 'schedule_interview', 'تم تحديد موعد المقابلة');
} catch (\Throwable $e) {
// Non-blocking — workflow might not support this transition from current state
}
}
EventBus::dispatch('interview.scheduled', [
'interview_id' => (int) $interview->id,
'member_id' => (int) $memberId,
'date' => $scheduledDate,
'time' => $scheduledTime,
]);
return $this->redirect('/interviews')->withSuccess('تم تحديد موعد المقابلة: ' . $scheduledDate . ($scheduledTime ? ' الساعة ' . $scheduledTime : ''));
}
public function reschedule(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$interview = $db->selectOne("SELECT i.*, m.full_name_ar as member_name FROM interviews i JOIN members m ON m.id = i.member_id WHERE i.id = ?", [(int) $id]);
if (!$interview || $interview['decision'] !== 'pending') {
return $this->redirect('/interviews')->withError('المقابلة غير موجودة أو تم البت فيها');
}
$newDate = trim($request->post('scheduled_date', ''));
$newTime = trim($request->post('scheduled_time', ''));
$reason = trim($request->post('reschedule_reason', ''));
if ($newDate === '' || $reason === '') {
return $this->redirect('/interviews')->withError('التاريخ الجديد وسبب التأجيل مطلوبان');
}
$db->update('interviews', [
'rescheduled_from' => $interview['scheduled_date'],
'scheduled_date' => $newDate,
'scheduled_time' => $newTime ?: null,
'reschedule_reason' => $reason,
'reschedule_count' => (int) $interview['reschedule_count'] + 1,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
EventBus::dispatch('interview.rescheduled', [
'interview_id' => (int) $id,
'member_id' => (int) $interview['member_id'],
'old_date' => $interview['scheduled_date'],
'new_date' => $newDate,
'reason' => $reason,
]);
return $this->redirect('/interviews')->withSuccess('تم تأجيل المقابلة إلى ' . $newDate);
}
public function decide(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$interview = $db->selectOne(
"SELECT i.*, m.full_name_ar as member_name, m.membership_number, m.workflow_instance_id
FROM interviews i JOIN members m ON m.id = i.member_id WHERE i.id = ?",
[(int) $id]
);
if (!$interview) {
return $this->redirect('/interviews')->withError('المقابلة غير موجودة');
}
if ($request->method() === 'GET') {
return $this->view('Interviews.Views.decide', ['interview' => $interview]);
}
// POST — process decision
$decision = trim($request->post('decision', ''));
$decisionNotes = trim($request->post('decision_notes', ''));
$boardMemberNames = trim($request->post('board_member_names', ''));
$boardReference = trim($request->post('board_decision_reference', ''));
if (!in_array($decision, ['accepted', 'rejected'])) {
return $this->redirect("/interviews/{$id}/decide")->withError('يجب اختيار قبول أو رفض');
}
$employee = App::getInstance()->currentEmployee();
$db->update('interviews', [
'decision' => $decision,
'decision_notes' => $decisionNotes ?: null,
'decision_date' => date('Y-m-d H:i:s'),
'board_member_names' => $boardMemberNames ?: null,
'board_decision_reference' => $boardReference ?: null,
'status' => $decision === 'accepted' ? 'completed_accepted' : 'completed_rejected',
'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [(int) $id]);
// Update member status
$newMemberStatus = $decision === 'accepted' ? 'payment_pending' : 'rejected';
$db->update('members', [
'status' => $newMemberStatus,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $interview['member_id']]);
// Transition workflow
if ($interview['workflow_instance_id']) {
try {
$transitionName = $decision === 'accepted' ? 'accept' : 'reject';
WorkflowEngine::transition((int) $interview['workflow_instance_id'], $transitionName, $decisionNotes ?: 'قرار مجلس الأمناء');
} catch (\Throwable $e) {
// Try alternate transition names
try {
$altName = $decision === 'accepted' ? 'payment_completed' : 'reject_from_review';
WorkflowEngine::transition((int) $interview['workflow_instance_id'], $altName, $decisionNotes ?: 'قرار مجلس الأمناء');
} catch (\Throwable $e2) {
// Non-blocking
}
}
}
EventBus::dispatch('interview.decided', [
'interview_id' => (int) $id,
'member_id' => (int) $interview['member_id'],
'decision' => $decision,
'notes' => $decisionNotes,
]);
$msg = $decision === 'accepted'
? 'تم قبول العضو — في انتظار السداد'
: 'تم رفض طلب العضوية';
return $this->redirect('/interviews')->withSuccess($msg);
}
public function memberInterviews(Request $request, string $memberId): Response
{
$interviews = Interview::getForMember((int) $memberId);
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $memberId]);
return $this->view('Interviews.Views.index', [
'rows' => $interviews,
'pagination' => ['last_page' => 1, 'current_page' => 1],
'filters' => ['search' => '', 'status' => '', 'decision' => '', 'date_from' => '', 'date_to' => ''],
'member' => $member,
]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Interviews\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class Interview extends Model
{
protected static string $table = 'interviews';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $dispatchEvents = true;
protected static array $fillable = [
'member_id', 'form_submission_id', 'scheduled_date', 'scheduled_time',
'location', 'rescheduled_from', 'reschedule_reason', 'reschedule_count',
'decision', 'decision_notes', 'decision_date', 'board_member_names',
'board_decision_reference', 'notification_sent', 'notification_sent_at',
'workflow_instance_id', 'status',
];
public static function getForMember(int $memberId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT i.*, m.full_name_ar as member_name, m.membership_number
FROM interviews i JOIN members m ON m.id = i.member_id
WHERE i.member_id = ? ORDER BY i.scheduled_date DESC",
[$memberId]
);
}
public static function getPendingForMember(int $memberId): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT * FROM interviews WHERE member_id = ? AND decision = 'pending' ORDER BY scheduled_date DESC LIMIT 1",
[$memberId]
);
}
public static function getUpcoming(int $daysAhead = 7): array
{
$db = App::getInstance()->db();
$futureDate = date('Y-m-d', strtotime("+{$daysAhead} days"));
return $db->select(
"SELECT i.*, m.full_name_ar as member_name, m.membership_number, m.phone_mobile
FROM interviews i JOIN members m ON m.id = i.member_id
WHERE i.decision = 'pending' AND i.scheduled_date BETWEEN CURDATE() AND ?
ORDER BY i.scheduled_date ASC, i.scheduled_time ASC",
[$futureDate]
);
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['status'])) {
$where .= ' AND i.status = ?';
$params[] = $filters['status'];
}
if (!empty($filters['decision'])) {
$where .= ' AND i.decision = ?';
$params[] = $filters['decision'];
}
if (!empty($filters['date_from'])) {
$where .= ' AND i.scheduled_date >= ?';
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where .= ' AND i.scheduled_date <= ?';
$params[] = $filters['date_to'];
}
if (!empty($filters['search'])) {
$where .= ' AND (m.full_name_ar LIKE ? OR m.membership_number LIKE ?)';
$s = '%' . $filters['search'] . '%';
$params[] = $s;
$params[] = $s;
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM interviews i JOIN members m ON m.id = i.member_id WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT i.*, m.full_name_ar as member_name, m.membership_number, m.phone_mobile
FROM interviews i JOIN members m ON m.id = i.member_id
WHERE {$where} ORDER BY i.scheduled_date DESC LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
public static function getDecisionLabel(string $decision): string
{
return match ($decision) {
'pending' => 'في الانتظار',
'accepted' => 'مقبول',
'rejected' => 'مرفوض',
default => $decision,
};
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/interviews', 'Interviews\Controllers\InterviewController@index', ['auth'], 'interview.view'],
['GET', '/interviews/schedule/{memberId}', 'Interviews\Controllers\InterviewController@schedule', ['auth'], 'interview.schedule'],
['POST', '/interviews/schedule/{memberId}', 'Interviews\Controllers\InterviewController@storeSchedule', ['auth'], 'interview.schedule'],
['POST', '/interviews/{id}/reschedule', 'Interviews\Controllers\InterviewController@reschedule', ['auth'], 'interview.schedule'],
['GET', '/interviews/{id}/decide', 'Interviews\Controllers\InterviewController@decide', ['auth'], 'interview.decide'],
['POST', '/interviews/{id}/decide', 'Interviews\Controllers\InterviewController@decide', ['auth'], 'interview.decide'],
['GET', '/interviews/member/{memberId}', 'Interviews\Controllers\InterviewController@memberInterviews', ['auth'], 'interview.view'],
];
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>قرار المقابلة — <?= e($interview['member_name']) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="padding:20px;margin-bottom:20px;">
<h4 style="color:#0D7377;margin-bottom:15px;">بيانات المقابلة</h4>
<table style="width:100%;max-width:600px;font-size:14px;">
<tr><td style="padding:6px 0;color:#6B7280;width:35%;">العضو</td><td style="padding:6px 0;font-weight:600;"><a href="/members/<?= (int) $interview['member_id'] ?>" style="color:#0D7377;"><?= e($interview['member_name']) ?></a></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">رقم العضوية</td><td style="padding:6px 0;"><?= e($interview['membership_number'] ?? 'لم يُحدد بعد') ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">تاريخ المقابلة</td><td style="padding:6px 0;font-weight:600;"><?= e($interview['scheduled_date']) ?> <?= $interview['scheduled_time'] ? e($interview['scheduled_time']) : '' ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">المكان</td><td style="padding:6px 0;"><?= e($interview['location'] ?? '—') ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">عدد التأجيلات</td><td style="padding:6px 0;"><?= (int) $interview['reschedule_count'] ?></td></tr>
<?php if ($interview['rescheduled_from']): ?>
<tr><td style="padding:6px 0;color:#6B7280;">تاريخ أصلي</td><td style="padding:6px 0;"><?= e($interview['rescheduled_from']) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">سبب التأجيل</td><td style="padding:6px 0;"><?= e($interview['reschedule_reason'] ?? '—') ?></td></tr>
<?php endif; ?>
<tr><td style="padding:6px 0;color:#6B7280;">القرار الحالي</td><td style="padding:6px 0;font-weight:700;color:<?= match($interview['decision']) { 'accepted' => '#059669', 'rejected' => '#DC2626', default => '#D97706' } ?>;"><?= e(\App\Modules\Interviews\Models\Interview::getDecisionLabel($interview['decision'])) ?></td></tr>
<?php if ($interview['decision_notes']): ?>
<tr><td style="padding:6px 0;color:#6B7280;">ملاحظات القرار</td><td style="padding:6px 0;"><?= e($interview['decision_notes']) ?></td></tr>
<?php endif; ?>
<?php if ($interview['board_member_names']): ?>
<tr><td style="padding:6px 0;color:#6B7280;">أعضاء المجلس</td><td style="padding:6px 0;"><?= e($interview['board_member_names']) ?></td></tr>
<?php endif; ?>
<?php if ($interview['board_decision_reference']): ?>
<tr><td style="padding:6px 0;color:#6B7280;">رقم محضر القرار</td><td style="padding:6px 0;"><?= e($interview['board_decision_reference']) ?></td></tr>
<?php endif; ?>
</table>
</div>
<?php if ($interview['decision'] === 'pending'): ?>
<form method="POST" action="/interviews/<?= (int) $interview['id'] ?>/decide">
<?= csrf_field() ?>
<div class="card" style="padding:20px;">
<h4 style="color:#0D7377;margin-bottom:15px;">إصدار القرار</h4>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">القرار <span style="color:#DC2626;">*</span></label>
<select name="decision" class="form-select" required>
<option value="">-- اختر --</option>
<option value="accepted">✓ قبول</option>
<option value="rejected">✗ رفض</option>
</select>
</div>
<div class="form-group">
<label class="form-label">رقم محضر القرار</label>
<input type="text" name="board_decision_reference" class="form-input">
</div>
<div class="form-group" style="grid-column:1/-1;">
<label class="form-label">أسماء أعضاء المجلس</label>
<input type="text" name="board_member_names" class="form-input" placeholder="اسم1، اسم2، اسم3">
</div>
<div class="form-group" style="grid-column:1/-1;">
<label class="form-label">ملاحظات القرار</label>
<textarea name="decision_notes" class="form-textarea" rows="3" placeholder="أسباب القبول أو الرفض..."></textarea>
</div>
</div>
</div>
<div style="margin-top:15px;display:flex;gap:10px;">
<button type="submit" class="btn btn-primary" onclick="return confirm('هل أنت متأكد من إصدار القرار؟')">إصدار القرار</button>
<a href="/interviews" class="btn btn-outline">إلغاء</a>
</div>
</form>
<?php endif; ?>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>المقابلات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/interviews" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div><label class="form-label" style="font-size:12px;">بحث</label><input type="text" name="q" value="<?= e($filters['search'] ?? '') ?>" placeholder="اسم العضو..." class="form-input" style="min-width:200px;"></div>
<div><label class="form-label" style="font-size:12px;">القرار</label>
<select name="decision" class="form-select"><option value="">الكل</option><option value="pending" <?= ($filters['decision'] ?? '') === 'pending' ? 'selected' : '' ?>>في الانتظار</option><option value="accepted" <?= ($filters['decision'] ?? '') === 'accepted' ? 'selected' : '' ?>>مقبول</option><option value="rejected" <?= ($filters['decision'] ?? '') === 'rejected' ? 'selected' : '' ?>>مرفوض</option></select></div>
<div><label class="form-label" style="font-size:12px;">من</label><input type="date" name="date_from" value="<?= e($filters['date_from'] ?? '') ?>" class="form-input"></div>
<div><label class="form-label" style="font-size:12px;">إلى</label><input type="date" name="date_to" value="<?= e($filters['date_to'] ?? '') ?>" class="form-input"></div>
<button type="submit" class="btn btn-outline">بحث</button>
<a href="/interviews" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<div class="card"><div class="table-responsive"><table class="data-table"><thead><tr><th>التاريخ</th><th>الوقت</th><th>العضو</th><th>الهاتف</th><th>المكان</th><th>تأجيلات</th><th>القرار</th><th>الإجراءات</th></tr></thead><tbody>
<?php foreach ($rows as $r): ?>
<tr style="<?= $r['decision'] === 'pending' && $r['scheduled_date'] < date('Y-m-d') ? 'background:#FEF2F2;' : '' ?>">
<td style="font-weight:600;white-space:nowrap;"><?= e($r['scheduled_date']) ?></td>
<td style="font-size:13px;"><?= e($r['scheduled_time'] ?? '—') ?></td>
<td><a href="/members/<?= (int) $r['member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($r['member_name']) ?></a><br><small style="color:#9CA3AF;"><?= e($r['membership_number'] ?? 'بدون رقم') ?></small></td>
<td style="direction:ltr;text-align:right;font-size:13px;"><?= e($r['phone_mobile'] ?? '—') ?></td>
<td style="font-size:13px;"><?= e($r['location'] ?? '—') ?></td>
<td style="text-align:center;"><?= (int) $r['reschedule_count'] ?></td>
<td>
<?php
$decColor = match($r['decision']) { 'accepted' => '#059669', 'rejected' => '#DC2626', default => '#D97706' };
$decLabel = \App\Modules\Interviews\Models\Interview::getDecisionLabel($r['decision']);
?>
<span style="color:<?= $decColor ?>;font-weight:600;"><?= e($decLabel) ?></span>
</td>
<td>
<div style="display:flex;gap:5px;flex-wrap:wrap;">
<?php if ($r['decision'] === 'pending'): ?>
<a href="/interviews/<?= (int) $r['id'] ?>/decide" class="btn btn-sm btn-primary">البت</a>
<form method="POST" action="/interviews/<?= (int) $r['id'] ?>/reschedule" style="display:flex;gap:3px;">
<?= csrf_field() ?>
<input type="date" name="scheduled_date" class="form-input" style="width:120px;font-size:11px;padding:3px 6px;" required min="<?= e(date('Y-m-d')) ?>">
<input type="hidden" name="reschedule_reason" value="إعادة جدولة">
<button type="submit" class="btn btn-sm btn-outline" style="font-size:11px;">تأجيل</button>
</form>
<?php else: ?>
<a href="/interviews/<?= (int) $r['id'] ?>/decide" class="btn btn-sm btn-outline">عرض</a>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?><tr><td colspan="8" style="text-align:center;padding:40px;color:#6B7280;">لا توجد مقابلات</td></tr><?php endif; ?>
</tbody></table></div></div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تحديد موعد مقابلة — <?= e($member['full_name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php if ($existing): ?>
<div style="padding:15px;background:#FFF7ED;border:1px solid #FED7AA;border-radius:8px;margin-bottom:20px;color:#D97706;">
<strong>⚠ يوجد مقابلة معلقة بالفعل</strong> — بتاريخ <?= e($existing['scheduled_date']) ?>
</div>
<?php endif; ?>
<form method="POST" action="/interviews/schedule/<?= (int) $member['id'] ?>">
<?= csrf_field() ?>
<div class="card" style="padding:20px;">
<div style="margin-bottom:15px;padding:10px;background:#EFF6FF;border:1px solid #BFDBFE;border-radius:8px;">
<strong>العضو:</strong> <?= e($member['full_name_ar']) ?><strong>الحالة:</strong> <?= e($member['status']) ?>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">تاريخ المقابلة <span style="color:#DC2626;">*</span></label>
<input type="date" name="scheduled_date" class="form-input" required min="<?= e(date('Y-m-d')) ?>">
</div>
<div class="form-group">
<label class="form-label">وقت المقابلة</label>
<input type="time" name="scheduled_time" class="form-input">
</div>
<div class="form-group" style="grid-column:1/-1;">
<label class="form-label">المكان</label>
<input type="text" name="location" class="form-input" placeholder="قاعة مجلس الأمناء">
</div>
</div>
</div>
<button type="submit" class="btn btn-primary" style="margin-top:15px;">تحديد الموعد</button>
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline" style="margin-top:15px;">إلغاء</a>
</form>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
MenuRegistry::register('interviews', [
'label_ar' => 'المقابلات',
'label_en' => 'Interviews',
'icon' => 'calendar',
'route' => '/interviews',
'permission' => 'interview.view',
'parent' => null,
'order' => 650,
'children' => [
['label_ar' => 'كل المقابلات', 'label_en' => 'All Interviews', 'route' => '/interviews', 'permission' => 'interview.view', 'order' => 1],
],
]);
PermissionRegistry::register('interviews', [
'interview.view' => ['ar' => 'عرض المقابلات', 'en' => 'View Interviews'],
'interview.schedule' => ['ar' => 'جدولة مقابلة', 'en' => 'Schedule Interview'],
'interview.decide' => ['ar' => 'البت في مقابلة', 'en' => 'Decide Interview'],
'interview.conduct' => ['ar' => 'إجراء مقابلة', 'en' => 'Conduct Interview'],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `interviews` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`member_id` BIGINT UNSIGNED NOT NULL,
`form_submission_id` BIGINT UNSIGNED NULL,
`scheduled_date` DATE NOT NULL,
`scheduled_time` TIME NULL,
`location` VARCHAR(200) NULL,
`rescheduled_from` DATE NULL,
`reschedule_reason` TEXT NULL,
`reschedule_count` INT UNSIGNED NOT NULL DEFAULT 0,
`decision` VARCHAR(30) NOT NULL DEFAULT 'pending',
`decision_notes` TEXT NULL,
`decision_date` TIMESTAMP NULL DEFAULT NULL,
`board_member_names` TEXT NULL,
`board_decision_reference` VARCHAR(100) NULL,
`notification_sent` TINYINT(1) NOT NULL DEFAULT 0,
`notification_sent_at` TIMESTAMP NULL DEFAULT NULL,
`workflow_instance_id` BIGINT UNSIGNED NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'scheduled',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
INDEX `idx_interviews_member` (`member_id`),
INDEX `idx_interviews_date` (`scheduled_date`),
INDEX `idx_interviews_decision` (`decision`),
INDEX `idx_interviews_status` (`status`),
CONSTRAINT `fk_interviews_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`id`),
CONSTRAINT `fk_interviews_form` FOREIGN KEY (`form_submission_id`) REFERENCES `form_submissions`(`id`) ON DELETE SET NULL,
CONSTRAINT `fk_interviews_workflow` FOREIGN KEY (`workflow_instance_id`) REFERENCES `workflow_instances`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `interviews`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `carnets` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`member_id` BIGINT UNSIGNED NOT NULL,
`carnet_number` VARCHAR(50) NOT NULL,
`carnet_type` VARCHAR(30) NOT NULL DEFAULT 'regular',
`qr_code_data` VARCHAR(500) NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`issued_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`issued_by` BIGINT UNSIGNED NULL,
`deactivated_at` TIMESTAMP NULL DEFAULT NULL,
`deactivated_reason` VARCHAR(200) NULL,
`replacement_count` INT UNSIGNED NOT NULL DEFAULT 0,
`previous_carnet_id` BIGINT UNSIGNED NULL,
`print_count` INT UNSIGNED NOT NULL DEFAULT 0,
`last_printed_at` TIMESTAMP NULL DEFAULT NULL,
`last_printed_by` BIGINT UNSIGNED NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
UNIQUE KEY `uq_carnets_number` (`carnet_number`),
INDEX `idx_carnets_member` (`member_id`),
INDEX `idx_carnets_active` (`is_active`),
INDEX `idx_carnets_type` (`carnet_type`),
CONSTRAINT `fk_carnets_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`id`),
CONSTRAINT `fk_carnets_previous` FOREIGN KEY (`previous_carnet_id`) REFERENCES `carnets`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `carnets`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `documents` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`member_id` BIGINT UNSIGNED NOT NULL,
`document_type` VARCHAR(50) NOT NULL,
`original_filename` VARCHAR(500) NOT NULL,
`stored_filename` VARCHAR(500) NOT NULL,
`file_path` VARCHAR(1000) NOT NULL,
`file_size` INT UNSIGNED NOT NULL DEFAULT 0,
`mime_type` VARCHAR(100) NOT NULL,
`related_entity_type` VARCHAR(100) NULL,
`related_entity_id` BIGINT UNSIGNED NULL,
`description` TEXT NULL,
`uploaded_by` BIGINT UNSIGNED NULL,
`uploaded_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`is_archived` TINYINT(1) NOT NULL DEFAULT 0,
`archived_at` TIMESTAMP NULL DEFAULT NULL,
`archived_by` BIGINT UNSIGNED NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_documents_member` (`member_id`),
INDEX `idx_documents_type` (`document_type`),
INDEX `idx_documents_related` (`related_entity_type`, `related_entity_id`),
INDEX `idx_documents_archived` (`is_archived`),
CONSTRAINT `fk_documents_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `documents`",
];
\ No newline at end of file
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