Commit 65a8298d authored by Administrator's avatar Administrator

Update 54 files via Son of Anton

parent 40294b2d
APP_URL=http://localhost/the-club-erp/public
APP_DEBUG=true
APP_ENV=local
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=the_club_erp
DB_USER=root
DB_PASS=
SMS_PROVIDER=
SMS_API_KEY=
SMS_SENDER_ID=
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
final class App
{
private static ?App $instance = null;
private Config $config;
private Database $database;
private Session $session;
private Router $router;
private ?object $currentEmployee = null;
private ?array $currentBranch = null;
private array $bindings = [];
private bool $booted = false;
private function __construct() {}
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function boot(): self
{
if ($this->booted) {
return $this;
}
date_default_timezone_set('Africa/Cairo');
$this->config = new Config();
$this->config->loadAll();
$this->database = new Database(
$this->config->get('database.host', '127.0.0.1'),
(int) $this->config->get('database.port', '3306'),
$this->config->get('database.name', 'the_club_erp'),
$this->config->get('database.user', 'root'),
$this->config->get('database.pass', ''),
$this->config->get('database.charset', 'utf8mb4')
);
$this->session = new Session(
(int) $this->config->get('app.session_lifetime', 30)
);
$this->session->start();
$this->config->loadFromDatabase($this->database);
$this->router = new Router();
$this->discoverModuleBootstraps();
$this->discoverModuleRoutes();
$this->booted = true;
return $this;
}
private function discoverModuleBootstraps(): void
{
$pattern = $this->basePath() . '/app/Modules/*/bootstrap.php';
$files = glob($pattern);
if ($files === false) {
return;
}
sort($files);
foreach ($files as $file) {
require_once $file;
}
}
private function discoverModuleRoutes(): void
{
$pattern = $this->basePath() . '/app/Modules/*/Routes.php';
$files = glob($pattern);
if ($files === false) {
return;
}
sort($files);
foreach ($files as $file) {
$routes = require $file;
if (is_array($routes)) {
foreach ($routes as $route) {
$this->router->addRoute(
$route[0],
$route[1],
$route[2],
$route[3] ?? [],
$route[4] ?? null
);
}
}
}
}
public function config(string $key = null, $default = null)
{
if ($key === null) {
return $this->config;
}
return $this->config->get($key, $default);
}
public function db(): Database
{
return $this->database;
}
public function session(): Session
{
return $this->session;
}
public function router(): Router
{
return $this->router;
}
public function setCurrentEmployee(?object $employee): void
{
$this->currentEmployee = $employee;
}
public function currentEmployee(): ?object
{
return $this->currentEmployee;
}
public function setCurrentBranch(?array $branch): void
{
$this->currentBranch = $branch;
}
public function currentBranch(): ?array
{
return $this->currentBranch;
}
public function bind(string $key, $value): void
{
$this->bindings[$key] = $value;
}
public function resolve(string $key, $default = null)
{
return $this->bindings[$key] ?? $default;
}
public function basePath(): string
{
return Autoloader::basePath();
}
public function publicPath(): string
{
return $this->basePath() . '/public';
}
public function storagePath(): string
{
return $this->basePath() . '/storage';
}
public function isDebug(): bool
{
return (bool) $this->config->get('app.debug', false);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
final class Autoloader
{
private static bool $registered = false;
private static array $prefixes = [
'App\\Core\\' => 'app/Core/',
'App\\Modules\\' => 'app/Modules/',
'App\\Middleware\\' => 'app/Middleware/',
'App\\Shared\\' => 'app/Shared/',
];
public static function register(): void
{
if (self::$registered) {
return;
}
spl_autoload_register([self::class, 'load']);
self::$registered = true;
$helpersPath = self::basePath() . '/app/Core/Helpers.php';
if (file_exists($helpersPath)) {
require_once $helpersPath;
}
}
public static function load(string $class): void
{
foreach (self::$prefixes as $prefix => $dir) {
if (strpos($class, $prefix) === 0) {
$relativeClass = substr($class, strlen($prefix));
$file = self::basePath() . '/' . $dir . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) {
require_once $file;
return;
}
}
}
}
public static function basePath(): string
{
return dirname(__DIR__, 2);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
final class CSRF
{
private static string $tokenKey = '_csrf_token';
public static function generate(): string
{
$token = bin2hex(random_bytes(32));
$session = App::getInstance()->session();
$session->set(self::$tokenKey, $token);
return $token;
}
public static function token(): string
{
$session = App::getInstance()->session();
$token = $session->get(self::$tokenKey);
if ($token === null) {
$token = self::generate();
}
return $token;
}
public static function validate(string $token): bool
{
$session = App::getInstance()->session();
$stored = $session->get(self::$tokenKey, '');
return hash_equals($stored, $token);
}
public static function field(): string
{
return '<input type="hidden" name="_csrf_token" value="' . self::token() . '">';
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
final class Config
{
private array $data = [];
public function loadAll(): void
{
$configDir = Autoloader::basePath() . '/config';
$files = glob($configDir . '/*.php');
if ($files === false) {
return;
}
foreach ($files as $file) {
$key = basename($file, '.php');
$values = require $file;
if (is_array($values)) {
$this->data[$key] = $values;
}
}
}
public function loadFromDatabase(Database $db): void
{
try {
if (!$db->tableExists('system_config')) {
return;
}
$rows = $db->select("SELECT config_key, config_value, config_type FROM system_config");
foreach ($rows as $row) {
$value = $this->castValue($row['config_value'], $row['config_type']);
$this->set($row['config_key'], $value);
}
} catch (\Exception $e) {
// DB config loading is optional - silently continue
}
}
private function castValue(?string $value, string $type)
{
if ($value === null) {
return null;
}
return match ($type) {
'integer' => (int) $value,
'float' => (float) $value,
'boolean' => in_array(strtolower($value), ['1', 'true', 'yes']),
'json' => json_decode($value, true),
default => $value,
};
}
public function get(string $key, $default = null)
{
$keys = explode('.', $key);
$value = $this->data;
foreach ($keys as $k) {
if (!is_array($value) || !array_key_exists($k, $value)) {
return $default;
}
$value = $value[$k];
}
return $value;
}
public function set(string $key, $value): void
{
$keys = explode('.', $key);
$data = &$this->data;
foreach ($keys as $i => $k) {
if ($i === count($keys) - 1) {
$data[$k] = $value;
} else {
if (!isset($data[$k]) || !is_array($data[$k])) {
$data[$k] = [];
}
$data = &$data[$k];
}
}
}
public function all(): array
{
return $this->data;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
abstract class Controller
{
protected Request $request;
protected array $middleware = [];
public function __construct(Request $request)
{
$this->request = $request;
}
protected function view(string $viewPath, array $data = []): Response
{
$template = new Template();
$html = $template->render($viewPath, $data);
return (new Response())->html($html);
}
protected function json($data, int $status = 200): Response
{
return (new Response())->json($data, $status);
}
protected function redirect(string $url): Response
{
return (new Response())->redirect($url);
}
protected function back(): Response
{
return (new Response())->back();
}
protected function validate(array $data, array $rules): array
{
$validator = new Validator();
$result = $validator->validate($data, $rules);
if ($result->fails()) {
if ($this->request->isAjax() || $this->request->isJson()) {
$response = (new Response())->json([
'success' => false,
'errors' => $result->errors(),
], 422);
$response->send();
exit;
}
App::getInstance()->session()->flash('_errors', $result->errors());
App::getInstance()->session()->flash('_old_input', $data);
$referer = $_SERVER['HTTP_REFERER'] ?? '/';
$response = (new Response())->redirect($referer);
$response->send();
exit;
}
return $result->validated();
}
protected function authorize(string $permission): void
{
$employee = App::getInstance()->currentEmployee();
if (!$employee) {
$response = (new Response())->redirect('/login');
$response->send();
exit;
}
if (method_exists($employee, 'hasPermission') && !$employee->hasPermission($permission)) {
$response = (new Response())->html(
'<html dir="rtl" lang="ar"><head><meta charset="utf-8"><title>غير مصرح</title>'
. '<style>body{font-family:Cairo,sans-serif;text-align:center;padding:60px;background:#f9fafb;}'
. 'h1{color:#DC2626;font-size:48px;}p{color:#6B7280;}</style></head>'
. '<body><h1>403</h1><p>غير مصرح لك بالوصول لهذه الصفحة</p><a href="/">العودة للرئيسية</a></body></html>',
403
);
$response->send();
exit;
}
}
protected function currentEmployee(): ?object
{
return App::getInstance()->currentEmployee();
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
use PDO;
use PDOStatement;
use PDOException;
final class Database
{
private ?PDO $pdo = null;
private string $host;
private int $port;
private string $name;
private string $user;
private string $pass;
private string $charset;
private array $queryLog = [];
private bool $queryLogEnabled = false;
private array $beforeInsertCallbacks = [];
private array $afterInsertCallbacks = [];
private array $beforeUpdateCallbacks = [];
private array $afterUpdateCallbacks = [];
private array $afterDeleteCallbacks = [];
public function __construct(string $host, int $port, string $name, string $user, string $pass, string $charset = 'utf8mb4')
{
$this->host = $host;
$this->port = $port;
$this->name = $name;
$this->user = $user;
$this->pass = $pass;
$this->charset = $charset;
}
private function connect(): PDO
{
if ($this->pdo === null) {
$dsn = "mysql:host={$this->host};port={$this->port};dbname={$this->name};charset={$this->charset}";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES '{$this->charset}' COLLATE '{$this->charset}_unicode_ci', sql_mode='STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'",
];
$this->pdo = new PDO($dsn, $this->user, $this->pass, $options);
}
return $this->pdo;
}
public function pdo(): PDO
{
return $this->connect();
}
public function query(string $sql, array $params = []): PDOStatement
{
$start = microtime(true);
$stmt = $this->connect()->prepare($sql);
$stmt->execute($params);
$elapsed = (microtime(true) - $start) * 1000;
if ($this->queryLogEnabled) {
$this->queryLog[] = [
'sql' => $sql,
'params' => $params,
'time' => round($elapsed, 2),
];
}
return $stmt;
}
public function select(string $sql, array $params = []): array
{
return $this->query($sql, $params)->fetchAll();
}
public function selectOne(string $sql, array $params = []): ?array
{
$result = $this->query($sql, $params)->fetch();
return $result !== false ? $result : null;
}
public function insert(string $table, array $data): int
{
foreach ($this->beforeInsertCallbacks as $cb) {
$cb($table, $data, null);
}
$columns = implode(', ', array_map(fn($c) => "`{$c}`", array_keys($data)));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO `{$table}` ({$columns}) VALUES ({$placeholders})";
$this->query($sql, array_values($data));
$id = (int) $this->connect()->lastInsertId();
foreach ($this->afterInsertCallbacks as $cb) {
$cb($table, $data, $id);
}
return $id;
}
public function update(string $table, array $data, string $where, array $whereParams = []): int
{
foreach ($this->beforeUpdateCallbacks as $cb) {
$cb($table, $data, null);
}
$set = implode(', ', array_map(fn($c) => "`{$c}` = ?", array_keys($data)));
$sql = "UPDATE `{$table}` SET {$set} WHERE {$where}";
$params = array_merge(array_values($data), $whereParams);
$stmt = $this->query($sql, $params);
$count = $stmt->rowCount();
foreach ($this->afterUpdateCallbacks as $cb) {
$cb($table, $data, null);
}
return $count;
}
public function delete(string $table, string $where, array $whereParams = []): int
{
$sql = "DELETE FROM `{$table}` WHERE {$where}";
$stmt = $this->query($sql, $whereParams);
$count = $stmt->rowCount();
foreach ($this->afterDeleteCallbacks as $cb) {
$cb($table, [], null);
}
return $count;
}
public function beginTransaction(): bool
{
return $this->connect()->beginTransaction();
}
public function commit(): bool
{
return $this->connect()->commit();
}
public function rollBack(): bool
{
return $this->connect()->rollBack();
}
public function inTransaction(): bool
{
return $this->connect()->inTransaction();
}
public function lastInsertId(): int
{
return (int) $this->connect()->lastInsertId();
}
public function raw(string $sql): PDOStatement
{
return $this->connect()->query($sql);
}
public function enableQueryLog(): void
{
$this->queryLogEnabled = true;
}
public function disableQueryLog(): void
{
$this->queryLogEnabled = false;
}
public function getQueryLog(): array
{
return $this->queryLog;
}
public function onBeforeInsert(callable $fn): void
{
$this->beforeInsertCallbacks[] = $fn;
}
public function onAfterInsert(callable $fn): void
{
$this->afterInsertCallbacks[] = $fn;
}
public function onBeforeUpdate(callable $fn): void
{
$this->beforeUpdateCallbacks[] = $fn;
}
public function onAfterUpdate(callable $fn): void
{
$this->afterUpdateCallbacks[] = $fn;
}
public function onAfterDelete(callable $fn): void
{
$this->afterDeleteCallbacks[] = $fn;
}
public function tableExists(string $table): bool
{
$stmt = $this->query("SHOW TABLES LIKE ?", [$table]);
return $stmt->rowCount() > 0;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
final class EventBus
{
private static array $listeners = [];
public static function listen(string $event, callable $handler, int $priority = 0): void
{
self::$listeners[$event][] = [
'handler' => $handler,
'priority' => $priority,
];
}
public static function dispatch(string $event, array &$data = []): array
{
if (!isset(self::$listeners[$event])) {
return [];
}
$sorted = self::$listeners[$event];
usort($sorted, fn($a, $b) => $b['priority'] <=> $a['priority']);
$results = [];
foreach ($sorted as $listener) {
$result = ($listener['handler'])($data);
if ($result !== null) {
$results[] = $result;
}
}
return $results;
}
public static function hasListeners(string $event): bool
{
return !empty(self::$listeners[$event]);
}
public static function getListeners(string $event): array
{
return self::$listeners[$event] ?? [];
}
public static function clearListeners(string $event): void
{
unset(self::$listeners[$event]);
}
public static function dispatchAsync(string $event, array $data = []): void
{
try {
$db = App::getInstance()->db();
$db->insert('async_event_queue', [
'event_name' => $event,
'event_data_json' => json_encode($data, JSON_UNESCAPED_UNICODE),
'status' => 'pending',
'attempts' => 0,
'max_attempts' => 3,
'created_at' => date('Y-m-d H:i:s'),
]);
} catch (\Exception $e) {
Logger::error('Failed to queue async event: ' . $e->getMessage(), ['event' => $event]);
}
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
final class ExceptionHandler
{
public static function register(): void
{
set_exception_handler([self::class, 'handleException']);
set_error_handler([self::class, 'handleError']);
}
public static function handleException(\Throwable $e): void
{
Logger::error($e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
]);
$data = ['exception' => $e];
EventBus::dispatch('system.exception', $data);
$debug = false;
try {
$debug = App::getInstance()->isDebug();
} catch (\Throwable $t) {
$debug = env('APP_DEBUG', false);
}
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
if ($debug) {
echo '<html dir="rtl" lang="ar"><head><meta charset="utf-8"><title>خطأ</title>'
. '<style>body{font-family:monospace,Cairo;padding:20px;background:#fef2f2;direction:ltr;text-align:left;}'
. 'h1{color:#DC2626;}pre{background:#1a1a2e;color:#e5e7eb;padding:15px;border-radius:8px;overflow-x:auto;white-space:pre-wrap;}'
. '.info{background:#fff;padding:10px;border:1px solid #e5e7eb;border-radius:4px;margin-bottom:10px;}</style></head><body>'
. '<h1>💀 Exception: ' . htmlspecialchars(get_class($e)) . '</h1>'
. '<div class="info"><strong>Message:</strong> ' . htmlspecialchars($e->getMessage()) . '</div>'
. '<div class="info"><strong>File:</strong> ' . htmlspecialchars($e->getFile()) . ':' . $e->getLine() . '</div>'
. '<pre>' . htmlspecialchars($e->getTraceAsString()) . '</pre>'
. '</body></html>';
} else {
echo '<html dir="rtl" lang="ar"><head><meta charset="utf-8"><title>خطأ في الخادم</title>'
. '<style>body{font-family:Cairo,sans-serif;text-align:center;padding:60px;background:#f9fafb;}'
. 'h1{color:#DC2626;font-size:48px;}p{color:#6B7280;font-size:16px;}</style></head>'
. '<body><h1>500</h1><p>عفواً، حدث خطأ في الخادم. يرجى المحاولة لاحقاً.</p></body></html>';
}
exit(1);
}
public static function handleError(int $errno, string $errstr, string $errfile, int $errline): bool
{
if (!(error_reporting() & $errno)) {
return false;
}
throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\App;
use App\Core\Database;
use App\Core\Response;
use App\Core\CSRF;
function env(string $key, $default = null)
{
return $_ENV[$key] ?? $_SERVER[$key] ?? $default;
}
function app(): App
{
return App::getInstance();
}
function db(): Database
{
return App::getInstance()->db();
}
function config(string $key = null, $default = null)
{
if ($key === null) {
return App::getInstance()->config();
}
return App::getInstance()->config($key, $default);
}
function session(string $key = null, $default = null)
{
$session = App::getInstance()->session();
if ($key === null) {
return $session;
}
return $session->get($key, $default);
}
function redirect(string $url): Response
{
return (new Response())->redirect($url);
}
function url(string $path = ''): string
{
$base = rtrim(App::getInstance()->config('app.url', ''), '/');
return $base . '/' . ltrim($path, '/');
}
function route(string $name, array $params = []): string
{
return \App\Core\Router::url($name, $params);
}
function old(string $key, $default = '')
{
$old = App::getInstance()->session()->get('_old_input', []);
return $old[$key] ?? $default;
}
function csrf_field(): string
{
return CSRF::field();
}
function e(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8');
}
function __(?string $key, ?string $default = null): string
{
return $key ?? $default ?? '';
}
function now(): string
{
return date('Y-m-d H:i:s');
}
function today(): string
{
return date('Y-m-d');
}
function money(?string $amount): string
{
if ($amount === null || $amount === '') return '0.00 ج.م';
$formatted = number_format((float) $amount, 2, '.', ',');
return $formatted . ' ج.م';
}
function percentage(?string $value): string
{
if ($value === null) return '0%';
return $value . '%';
}
function age_from_dob(string $dob): array
{
try {
$birth = new \DateTime($dob);
$now = new \DateTime();
$diff = $now->diff($birth);
return ['years' => $diff->y, 'months' => $diff->m];
} catch (\Exception $e) {
return ['years' => 0, 'months' => 0];
}
}
function arabic_date(string $date): string
{
$months = [
1 => 'يناير', 2 => 'فبراير', 3 => 'مارس', 4 => 'أبريل',
5 => 'مايو', 6 => 'يونيو', 7 => 'يوليو', 8 => 'أغسطس',
9 => 'سبتمبر', 10 => 'أكتوبر', 11 => 'نوفمبر', 12 => 'ديسمبر',
];
try {
$dt = new \DateTime($date);
return $dt->format('d') . ' ' . $months[(int) $dt->format('m')] . ' ' . $dt->format('Y');
} catch (\Exception $e) {
return $date;
}
}
function is_arabic(string $text): bool
{
return (bool) preg_match('/[\p{Arabic}]/u', $text);
}
function generate_uuid(): string
{
$data = random_bytes(16);
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
function mask_national_id(string $nid): string
{
if (strlen($nid) !== 14) return $nid;
return substr($nid, 0, 3) . str_repeat('*', 9) . substr($nid, 12, 2);
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
final class Logger
{
public static function info(string $message, array $context = []): void
{
self::write('INFO', $message, $context);
}
public static function warning(string $message, array $context = []): void
{
self::write('WARNING', $message, $context);
}
public static function error(string $message, array $context = []): void
{
self::write('ERROR', $message, $context);
}
public static function debug(string $message, array $context = []): void
{
self::write('DEBUG', $message, $context);
}
private static function write(string $level, string $message, array $context): void
{
$logDir = Autoloader::basePath() . '/storage/logs';
if (!is_dir($logDir)) {
@mkdir($logDir, 0775, true);
}
$file = $logDir . '/app_' . date('Y-m-d') . '.log';
$employeeId = 0;
try {
$employee = App::getInstance()->currentEmployee();
if ($employee) {
$employeeId = $employee->id ?? ($employee['id'] ?? 0);
}
} catch (\Throwable $e) {
// ignore
}
$contextStr = !empty($context) ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
$line = sprintf(
"[%s] [%s] [employee_id:%d] %s%s\n",
date('Y-m-d H:i:s'),
$level,
$employeeId,
$message,
$contextStr
);
@file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core\Middleware;
use App\Core\Request;
use App\Core\Response;
interface MiddlewareInterface
{
public function handle(Request $request, callable $next): Response;
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core\Migration;
use App\Core\Database;
use App\Core\Autoloader;
final class MigrationRunner
{
private Database $db;
public function __construct(Database $db)
{
$this->db = $db;
}
public function migrate(): array
{
$this->ensureMigrationsTable();
$ran = $this->getRanMigrations();
$batch = $this->getNextBatch();
$files = $this->getMigrationFiles();
$results = [];
foreach ($files as $file) {
$name = basename($file, '.php');
if (in_array($name, $ran)) {
continue;
}
$migration = require $file;
if (is_array($migration) && isset($migration['up'])) {
$this->db->raw($migration['up']);
} elseif (is_object($migration) && method_exists($migration, 'up')) {
$migration->up($this->db);
}
$this->db->insert('migrations', [
'migration' => $name,
'batch' => $batch,
'executed_at' => date('Y-m-d H:i:s'),
]);
$results[] = $name;
}
return $results;
}
public function rollback(): array
{
$this->ensureMigrationsTable();
$batch = $this->getLastBatch();
if ($batch === 0) {
return [];
}
$migrations = $this->db->select(
"SELECT migration FROM migrations WHERE batch = ? ORDER BY id DESC",
[$batch]
);
$results = [];
foreach ($migrations as $row) {
$file = $this->getMigrationDir() . '/' . $row['migration'] . '.php';
if (file_exists($file)) {
$migration = require $file;
if (is_array($migration) && isset($migration['down'])) {
$this->db->raw($migration['down']);
} elseif (is_object($migration) && method_exists($migration, 'down')) {
$migration->down($this->db);
}
}
$this->db->delete('migrations', '`migration` = ?', [$row['migration']]);
$results[] = $row['migration'];
}
return $results;
}
public function status(): array
{
$this->ensureMigrationsTable();
$ran = $this->getRanMigrations();
$files = $this->getMigrationFiles();
$status = [];
foreach ($files as $file) {
$name = basename($file, '.php');
$status[] = [
'migration' => $name,
'status' => in_array($name, $ran) ? 'Ran' : 'Pending',
];
}
return $status;
}
private function ensureMigrationsTable(): void
{
if (!$this->db->tableExists('migrations')) {
$this->db->raw("
CREATE TABLE `migrations` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`migration` VARCHAR(255) NOT NULL,
`batch` INT UNSIGNED NOT NULL,
`executed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uq_migrations_name` (`migration`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
}
private function getRanMigrations(): array
{
$rows = $this->db->select("SELECT migration FROM migrations ORDER BY id");
return array_column($rows, 'migration');
}
private function getNextBatch(): int
{
return $this->getLastBatch() + 1;
}
private function getLastBatch(): int
{
$row = $this->db->selectOne("SELECT MAX(batch) as max_batch FROM migrations");
return (int) ($row['max_batch'] ?? 0);
}
private function getMigrationFiles(): array
{
$dir = $this->getMigrationDir();
$files = glob($dir . '/Phase_*.php');
if ($files === false) {
return [];
}
sort($files);
return $files;
}
private function getMigrationDir(): string
{
return Autoloader::basePath() . '/database/migrations';
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
abstract class Model
{
protected static string $table = '';
protected static string $primaryKey = 'id';
protected static array $fillable = [];
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $dispatchEvents = true;
protected static bool $auditEnabled = true;
protected array $attributes = [];
protected bool $exists = false;
public function __construct(array $attributes = [])
{
$this->attributes = $attributes;
$this->exists = isset($attributes[static::$primaryKey]);
}
public static function query(): QueryBuilder
{
$qb = (new QueryBuilder())->table(static::$table);
if (!static::$softDelete) {
$qb = $qb->noSoftDelete();
}
return $qb;
}
public static function find(int $id): ?static
{
$row = static::query()->where(static::$primaryKey, '=', $id)->first();
if ($row === null) {
return null;
}
$instance = new static($row);
$instance->exists = true;
return $instance;
}
public static function findOrFail(int $id): static
{
$instance = static::find($id);
if ($instance === null) {
throw new \RuntimeException("Record not found in " . static::$table . " with id {$id}");
}
return $instance;
}
public static function create(array $data): static
{
$filtered = [];
foreach ($data as $key => $value) {
if (in_array($key, static::$fillable, true)) {
$filtered[$key] = $value;
}
}
if (static::$timestamps) {
$filtered['created_at'] = date('Y-m-d H:i:s');
$filtered['updated_at'] = date('Y-m-d H:i:s');
}
$employee = App::getInstance()->currentEmployee();
if ($employee) {
$filtered['created_by'] = $employee->id ?? ($employee['id'] ?? null);
}
$db = App::getInstance()->db();
$id = $db->insert(static::$table, $filtered);
$instance = static::find($id);
if (static::$dispatchEvents) {
$singular = rtrim(static::$table, 's');
EventBus::dispatch("{$singular}.created", ['id' => $id, 'data' => $filtered]);
}
return $instance;
}
public function save(): bool
{
$db = App::getInstance()->db();
if ($this->exists) {
return $this->update($this->attributes);
}
$id = $db->insert(static::$table, $this->attributes);
$this->attributes[static::$primaryKey] = $id;
$this->exists = true;
return true;
}
public function update(array $data): bool
{
$filtered = [];
foreach ($data as $key => $value) {
if (in_array($key, static::$fillable, true)) {
$filtered[$key] = $value;
}
}
if (static::$timestamps) {
$filtered['updated_at'] = date('Y-m-d H:i:s');
}
$employee = App::getInstance()->currentEmployee();
if ($employee) {
$filtered['updated_by'] = $employee->id ?? ($employee['id'] ?? null);
}
$db = App::getInstance()->db();
$db->update(static::$table, $filtered, '`' . static::$primaryKey . '` = ?', [$this->id]);
$this->attributes = array_merge($this->attributes, $filtered);
if (static::$dispatchEvents) {
$singular = rtrim(static::$table, 's');
EventBus::dispatch("{$singular}.updated", ['id' => $this->id, 'data' => $filtered]);
}
return true;
}
public function archive(): bool
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$data = [
'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
];
if ($employee) {
$data['archived_by'] = $employee->id ?? ($employee['id'] ?? null);
}
$db->update(static::$table, $data, '`' . static::$primaryKey . '` = ?', [$this->id]);
$this->attributes = array_merge($this->attributes, $data);
if (static::$dispatchEvents) {
$singular = rtrim(static::$table, 's');
EventBus::dispatch("{$singular}.archived", ['id' => $this->id]);
}
return true;
}
public function toArray(): array
{
return $this->attributes;
}
public function __get(string $key)
{
return $this->attributes[$key] ?? null;
}
public function __set(string $key, $value): void
{
$this->attributes[$key] = $value;
}
public function __isset(string $key): bool
{
return isset($this->attributes[$key]);
}
protected function beforeSave(): void {}
protected function afterSave(): void {}
protected function beforeCreate(): void {}
protected function afterCreate(): void {}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
final class Pagination
{
public static function paginate(int $totalItems, int $perPage, int $currentPage): array
{
$perPage = max(1, $perPage);
$lastPage = max(1, (int) ceil($totalItems / $perPage));
$currentPage = max(1, min($currentPage, $lastPage));
$from = ($currentPage - 1) * $perPage + 1;
$to = min($currentPage * $perPage, $totalItems);
if ($totalItems === 0) {
$from = 0;
$to = 0;
}
$pages = self::buildPageNumbers($currentPage, $lastPage);
return [
'total' => $totalItems,
'per_page' => $perPage,
'current_page' => $currentPage,
'last_page' => $lastPage,
'from' => $from,
'to' => $to,
'has_prev' => $currentPage > 1,
'has_next' => $currentPage < $lastPage,
'prev_page' => $currentPage > 1 ? $currentPage - 1 : null,
'next_page' => $currentPage < $lastPage ? $currentPage + 1 : null,
'pages' => $pages,
];
}
private static function buildPageNumbers(int $current, int $last): array
{
if ($last <= 7) {
return range(1, $last);
}
$pages = [1];
$start = max(2, $current - 2);
$end = min($last - 1, $current + 2);
if ($start > 2) {
$pages[] = '...';
}
for ($i = $start; $i <= $end; $i++) {
$pages[] = $i;
}
if ($end < $last - 1) {
$pages[] = '...';
}
$pages[] = $last;
return $pages;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
final class QueryBuilder
{
private Database $db;
private string $table;
private array $selects = ['*'];
private array $wheres = [];
private array $bindings = [];
private array $joins = [];
private array $orderBys = [];
private array $groupBys = [];
private ?string $having = null;
private ?int $limitVal = null;
private ?int $offsetVal = null;
private bool $includeArchived = false;
private bool $onlyArchivedFlag = false;
private bool $hasSoftDelete = true;
public function __construct(?Database $db = null)
{
$this->db = $db ?? App::getInstance()->db();
}
public function table(string $name): self
{
$clone = clone $this;
$clone->table = $name;
return $clone;
}
public function select(string|array $columns = '*'): self
{
$clone = clone $this;
$clone->selects = is_array($columns) ? $columns : [$columns];
return $clone;
}
public function where(string $col, string $op, $val): self
{
$clone = clone $this;
$clone->wheres[] = ['AND', "`{$col}` {$op} ?"];
$clone->bindings[] = $val;
return $clone;
}
public function andWhere(string $col, string $op, $val): self
{
return $this->where($col, $op, $val);
}
public function orWhere(string $col, string $op, $val): self
{
$clone = clone $this;
$clone->wheres[] = ['OR', "`{$col}` {$op} ?"];
$clone->bindings[] = $val;
return $clone;
}
public function whereIn(string $col, array $values): self
{
if (empty($values)) {
return $this->whereRaw('1 = 0');
}
$clone = clone $this;
$placeholders = implode(', ', array_fill(0, count($values), '?'));
$clone->wheres[] = ['AND', "`{$col}` IN ({$placeholders})"];
$clone->bindings = array_merge($clone->bindings, array_values($values));
return $clone;
}
public function whereNull(string $col): self
{
$clone = clone $this;
$clone->wheres[] = ['AND', "`{$col}` IS NULL"];
return $clone;
}
public function whereNotNull(string $col): self
{
$clone = clone $this;
$clone->wheres[] = ['AND', "`{$col}` IS NOT NULL"];
return $clone;
}
public function whereBetween(string $col, $min, $max): self
{
$clone = clone $this;
$clone->wheres[] = ['AND', "`{$col}` BETWEEN ? AND ?"];
$clone->bindings[] = $min;
$clone->bindings[] = $max;
return $clone;
}
public function whereRaw(string $raw, array $bindings = []): self
{
$clone = clone $this;
$clone->wheres[] = ['AND', $raw];
$clone->bindings = array_merge($clone->bindings, $bindings);
return $clone;
}
public function join(string $table, string $on, string $type = 'INNER'): self
{
$clone = clone $this;
$clone->joins[] = "{$type} JOIN `{$table}` ON {$on}";
return $clone;
}
public function leftJoin(string $table, string $on): self
{
return $this->join($table, $on, 'LEFT');
}
public function orderBy(string $col, string $dir = 'ASC'): self
{
$clone = clone $this;
$dir = strtoupper($dir) === 'DESC' ? 'DESC' : 'ASC';
$clone->orderBys[] = "`{$col}` {$dir}";
return $clone;
}
public function groupBy(string $col): self
{
$clone = clone $this;
$clone->groupBys[] = "`{$col}`";
return $clone;
}
public function having(string $raw): self
{
$clone = clone $this;
$clone->having = $raw;
return $clone;
}
public function limit(int $n): self
{
$clone = clone $this;
$clone->limitVal = $n;
return $clone;
}
public function offset(int $n): self
{
$clone = clone $this;
$clone->offsetVal = $n;
return $clone;
}
public function withoutArchived(): self
{
$clone = clone $this;
$clone->includeArchived = false;
$clone->onlyArchivedFlag = false;
return $clone;
}
public function withArchived(): self
{
$clone = clone $this;
$clone->includeArchived = true;
$clone->onlyArchivedFlag = false;
return $clone;
}
public function onlyArchived(): self
{
$clone = clone $this;
$clone->onlyArchivedFlag = true;
$clone->includeArchived = true;
return $clone;
}
public function noSoftDelete(): self
{
$clone = clone $this;
$clone->hasSoftDelete = false;
return $clone;
}
public function when(bool $condition, callable $fn): self
{
if ($condition) {
return $fn($this);
}
return $this;
}
private function buildSql(string $selectOverride = null): array
{
$select = $selectOverride ?? implode(', ', $this->selects);
$sql = "SELECT {$select} FROM `{$this->table}`";
foreach ($this->joins as $join) {
$sql .= " {$join}";
}
$allWheres = $this->wheres;
if ($this->hasSoftDelete && !$this->includeArchived) {
$allWheres[] = ['AND', "`{$this->table}`.`is_archived` = 0"];
}
if ($this->hasSoftDelete && $this->onlyArchivedFlag) {
$allWheres[] = ['AND', "`{$this->table}`.`is_archived` = 1"];
}
$bindings = $this->bindings;
if (!empty($allWheres)) {
$sql .= ' WHERE ';
$parts = [];
foreach ($allWheres as $i => [$connector, $clause]) {
if ($i === 0) {
$parts[] = $clause;
} else {
$parts[] = $connector . ' ' . $clause;
}
}
$sql .= implode(' ', $parts);
}
if (!empty($this->groupBys)) {
$sql .= ' GROUP BY ' . implode(', ', $this->groupBys);
}
if ($this->having !== null) {
$sql .= ' HAVING ' . $this->having;
}
if (!empty($this->orderBys)) {
$sql .= ' ORDER BY ' . implode(', ', $this->orderBys);
}
if ($this->limitVal !== null) {
$sql .= ' LIMIT ' . $this->limitVal;
}
if ($this->offsetVal !== null) {
$sql .= ' OFFSET ' . $this->offsetVal;
}
return [$sql, $bindings];
}
public function get(): array
{
[$sql, $bindings] = $this->buildSql();
return $this->db->select($sql, $bindings);
}
public function first(): ?array
{
$clone = clone $this;
$clone->limitVal = 1;
[$sql, $bindings] = $clone->buildSql();
return $this->db->selectOne($sql, $bindings);
}
public function count(): int
{
[$sql, $bindings] = $this->buildSql('COUNT(*) as _cnt');
$result = $this->db->selectOne($sql, $bindings);
return (int) ($result['_cnt'] ?? 0);
}
public function sum(string $col): string
{
[$sql, $bindings] = $this->buildSql("COALESCE(SUM(`{$col}`), 0) as _sum");
$result = $this->db->selectOne($sql, $bindings);
return (string) ($result['_sum'] ?? '0');
}
public function paginate(int $perPage, int $currentPage): array
{
$totalItems = $this->count();
$pagination = Pagination::paginate($totalItems, $perPage, $currentPage);
$data = $this->limit($perPage)->offset(($currentPage - 1) * $perPage)->get();
return [
'data' => $data,
'pagination' => $pagination,
];
}
public function insertGetId(array $data): int
{
return $this->db->insert($this->table, $data);
}
public function insertRow(array $data): int
{
return $this->db->insert($this->table, $data);
}
public function updateRows(array $data): int
{
if (empty($this->wheres)) {
throw new \RuntimeException('Cannot update without WHERE clause');
}
$set = implode(', ', array_map(fn($c) => "`{$c}` = ?", array_keys($data)));
$sql = "UPDATE `{$this->table}` SET {$set}";
$bindings = array_values($data);
$sql .= ' WHERE ';
$parts = [];
foreach ($this->wheres as $i => [$connector, $clause]) {
if ($i === 0) {
$parts[] = $clause;
} else {
$parts[] = $connector . ' ' . $clause;
}
}
$sql .= implode(' ', $parts);
$bindings = array_merge($bindings, $this->bindings);
$stmt = $this->db->query($sql, $bindings);
return $stmt->rowCount();
}
public function softDelete(): int
{
return $this->updateRows([
'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
]);
}
public function toSql(): string
{
[$sql,] = $this->buildSql();
return $sql;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core\Registries;
final class ActionRegistry
{
private static array $items = [];
public static function register(string $actionName, callable $handler): void
{
self::$items[$actionName] = ['handler' => $handler];
}
public static function get(string $actionName): ?array
{
return self::$items[$actionName] ?? null;
}
public static function getAll(): array
{
return self::$items;
}
public static function has(string $actionName): bool
{
return isset(self::$items[$actionName]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core\Registries;
final class CalculatorRegistry
{
private static array $items = [];
public static function register(string $name, callable $calculator): void
{
self::$items[$name] = ['calculator' => $calculator];
}
public static function get(string $name): ?array
{
return self::$items[$name] ?? null;
}
public static function calculate(string $name, array $context = []): mixed
{
if (!isset(self::$items[$name])) {
throw new \RuntimeException("Calculator not found: {$name}");
}
return (self::$items[$name]['calculator'])($context);
}
public static function getAll(): array
{
return self::$items;
}
public static function has(string $name): bool
{
return isset(self::$items[$name]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core\Registries;
final class FieldTypeRegistry
{
private static array $items = [];
public static function register(string $typeCode, array $definition): void
{
self::$items[$typeCode] = $definition;
}
public static function get(string $typeCode): ?array
{
return self::$items[$typeCode] ?? null;
}
public static function getAll(): array
{
return self::$items;
}
public static function has(string $typeCode): bool
{
return isset(self::$items[$typeCode]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core\Registries;
final class MenuRegistry
{
private static array $items = [];
public static function register(string $key, array $definition): void
{
self::$items[$key] = $definition;
}
public static function get(string $key): ?array
{
return self::$items[$key] ?? null;
}
public static function getAll(): array
{
$items = self::$items;
uasort($items, fn($a, $b) => ($a['order'] ?? 999) <=> ($b['order'] ?? 999));
return $items;
}
public static function getVisible(array $userPermissions): array
{
$visible = [];
foreach (self::getAll() as $key => $item) {
$perm = $item['permission'] ?? null;
if ($perm === null || in_array($perm, $userPermissions) || in_array('*', $userPermissions)) {
$filtered = $item;
if (!empty($item['children'])) {
$filtered['children'] = array_filter($item['children'], function ($child) use ($userPermissions) {
$cp = $child['permission'] ?? null;
return $cp === null || in_array($cp, $userPermissions) || in_array('*', $userPermissions);
});
usort($filtered['children'], fn($a, $b) => ($a['order'] ?? 999) <=> ($b['order'] ?? 999));
}
$visible[$key] = $filtered;
}
}
return $visible;
}
public static function has(string $key): bool
{
return isset(self::$items[$key]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core\Registries;
final class PermissionRegistry
{
private static array $items = [];
private static array $groups = [];
public static function register(string $group, array $definitions): void
{
self::$groups[$group] = $definitions;
foreach ($definitions as $key => $labels) {
self::$items[$key] = array_merge($labels, ['group' => $group]);
}
}
public static function get(string $key): ?array
{
return self::$items[$key] ?? null;
}
public static function getAll(): array
{
return self::$items;
}
public static function getByGroup(string $group): array
{
return self::$groups[$group] ?? [];
}
public static function getAllGrouped(): array
{
return self::$groups;
}
public static function has(string $key): bool
{
return isset(self::$items[$key]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core\Registries;
final class ReportRegistry
{
private static array $items = [];
public static function register(string $key, array $definition): void
{
self::$items[$key] = $definition;
}
public static function get(string $key): ?array
{
return self::$items[$key] ?? null;
}
public static function getAll(): array
{
return self::$items;
}
public static function getByCategory(string $category): array
{
return array_filter(self::$items, fn($item) => ($item['category'] ?? '') === $category);
}
public static function has(string $key): bool
{
return isset(self::$items[$key]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core\Registries;
final class ValidatorRegistry
{
private static array $items = [];
public static function register(string $ruleName, callable $handler): void
{
self::$items[$ruleName] = ['handler' => $handler];
}
public static function get(string $ruleName): ?array
{
return self::$items[$ruleName] ?? null;
}
public static function getAll(): array
{
return self::$items;
}
public static function has(string $ruleName): bool
{
return isset(self::$items[$ruleName]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core\Registries;
final class WidgetRegistry
{
private static array $items = [];
public static function register(string $key, array $definition): void
{
self::$items[$key] = $definition;
}
public static function get(string $key): ?array
{
return self::$items[$key] ?? null;
}
public static function getAll(): array
{
$items = self::$items;
uasort($items, fn($a, $b) => ($a['order'] ?? 999) <=> ($b['order'] ?? 999));
return $items;
}
public static function has(string $key): bool
{
return isset(self::$items[$key]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
final class Request
{
private string $method;
private string $path;
private array $get;
private array $post;
private array $files;
private array $server;
private array $cookies;
private string $body;
private array $routeParams = [];
private array $attributes = [];
public function __construct()
{
$this->server = $_SERVER;
$this->get = $_GET;
$this->post = $_POST;
$this->files = $_FILES;
$this->cookies = $_COOKIE;
$this->body = file_get_contents('php://input') ?: '';
$this->method = strtoupper($this->server['REQUEST_METHOD'] ?? 'GET');
if ($this->method === 'POST') {
$override = $this->post['_method'] ?? null;
if ($override && in_array(strtoupper($override), ['PUT', 'PATCH', 'DELETE'])) {
$this->method = strtoupper($override);
}
}
$uri = $this->server['REQUEST_URI'] ?? '/';
$scriptName = $this->server['SCRIPT_NAME'] ?? '';
$basePath = dirname($scriptName);
if ($basePath !== '/' && $basePath !== '\\') {
$uri = substr($uri, strlen($basePath)) ?: '/';
}
$this->path = '/' . trim(parse_url($uri, PHP_URL_PATH) ?? '/', '/');
}
public static function capture(): self
{
return new self();
}
public function method(): string
{
return $this->method;
}
public function path(): string
{
return $this->path;
}
public function get(string $key, $default = null)
{
return $this->get[$key] ?? $default;
}
public function post(string $key, $default = null)
{
return $this->post[$key] ?? $default;
}
public function input(string $key, $default = null)
{
return $this->post[$key] ?? $this->get[$key] ?? $default;
}
public function all(): array
{
return array_merge($this->get, $this->post);
}
public function only(array $keys): array
{
$all = $this->all();
return array_intersect_key($all, array_flip($keys));
}
public function file(string $key): ?array
{
return $this->files[$key] ?? null;
}
public function hasFile(string $key): bool
{
return isset($this->files[$key]) && $this->files[$key]['error'] !== UPLOAD_ERR_NO_FILE;
}
public function isAjax(): bool
{
return strtolower($this->header('X-Requested-With') ?? '') === 'xmlhttprequest';
}
public function isJson(): bool
{
$contentType = $this->header('Content-Type') ?? '';
return str_contains($contentType, 'json');
}
public function ip(): string
{
$keys = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP', 'REMOTE_ADDR'];
foreach ($keys as $key) {
if (!empty($this->server[$key])) {
$ips = explode(',', $this->server[$key]);
return trim($ips[0]);
}
}
return '0.0.0.0';
}
public function userAgent(): string
{
return $this->server['HTTP_USER_AGENT'] ?? '';
}
public function bearerToken(): ?string
{
$auth = $this->header('Authorization') ?? '';
if (str_starts_with($auth, 'Bearer ')) {
return substr($auth, 7);
}
return null;
}
public function cookie(string $key, $default = null)
{
return $this->cookies[$key] ?? $default;
}
public function header(string $key): ?string
{
$serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $key));
return $this->server[$serverKey] ?? null;
}
public function body(): string
{
return $this->body;
}
public function jsonBody(): array
{
$decoded = json_decode($this->body, true);
return is_array($decoded) ? $decoded : [];
}
public function setRouteParams(array $params): void
{
$this->routeParams = $params;
}
public function routeParam(string $key, $default = null)
{
return $this->routeParams[$key] ?? $default;
}
public function routeParams(): array
{
return $this->routeParams;
}
public function setAttribute(string $key, $value): void
{
$this->attributes[$key] = $value;
}
public function getAttribute(string $key, $default = null)
{
return $this->attributes[$key] ?? $default;
}
public function queryString(): string
{
return $this->server['QUERY_STRING'] ?? '';
}
public function fullUrl(): string
{
$scheme = (!empty($this->server['HTTPS']) && $this->server['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $this->server['HTTP_HOST'] ?? 'localhost';
$uri = $this->server['REQUEST_URI'] ?? '/';
return $scheme . '://' . $host . $uri;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
final class Response
{
private int $statusCode = 200;
private array $headers = [];
private string $body = '';
private bool $sent = false;
public function html(string $content, int $status = 200): self
{
$this->statusCode = $status;
$this->headers['Content-Type'] = 'text/html; charset=utf-8';
$this->body = $content;
return $this;
}
public function json($data, int $status = 200): self
{
$this->statusCode = $status;
$this->headers['Content-Type'] = 'application/json; charset=utf-8';
$this->body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $this;
}
public function redirect(string $url, int $status = 302): self
{
$this->statusCode = $status;
$this->headers['Location'] = $url;
$this->body = '';
return $this;
}
public function back(): self
{
$referer = $_SERVER['HTTP_REFERER'] ?? '/';
return $this->redirect($referer);
}
public function download(string $filePath, string $filename): self
{
if (!file_exists($filePath)) {
return $this->html('File not found', 404);
}
$this->statusCode = 200;
$this->headers['Content-Type'] = 'application/octet-stream';
$this->headers['Content-Disposition'] = 'attachment; filename="' . $filename . '"';
$this->headers['Content-Length'] = (string) filesize($filePath);
$this->body = file_get_contents($filePath);
return $this;
}
public function withSession(string $key, $value): self
{
App::getInstance()->session()->flash($key, $value);
return $this;
}
public function withError(string $message): self
{
$session = App::getInstance()->session();
$alerts = $session->get('_alerts', []);
$alerts[] = ['type' => 'error', 'message' => $message];
$session->flash('_alerts', $alerts);
return $this;
}
public function withSuccess(string $message): self
{
$session = App::getInstance()->session();
$alerts = $session->get('_alerts', []);
$alerts[] = ['type' => 'success', 'message' => $message];
$session->flash('_alerts', $alerts);
return $this;
}
public function withWarning(string $message): self
{
$session = App::getInstance()->session();
$alerts = $session->get('_alerts', []);
$alerts[] = ['type' => 'warning', 'message' => $message];
$session->flash('_alerts', $alerts);
return $this;
}
public function withInfo(string $message): self
{
$session = App::getInstance()->session();
$alerts = $session->get('_alerts', []);
$alerts[] = ['type' => 'info', 'message' => $message];
$session->flash('_alerts', $alerts);
return $this;
}
public function withInput(array $data): self
{
App::getInstance()->session()->flash('_old_input', $data);
return $this;
}
public function withErrors(array $errors): self
{
App::getInstance()->session()->flash('_errors', $errors);
return $this;
}
public function status(int $code): self
{
$this->statusCode = $code;
return $this;
}
public function header(string $key, string $value): self
{
$this->headers[$key] = $value;
return $this;
}
public function pdf(string $html): self
{
$this->statusCode = 200;
$this->headers['Content-Type'] = 'text/html; charset=utf-8';
$this->body = $html;
return $this;
}
public function send(): void
{
if ($this->sent) {
return;
}
http_response_code($this->statusCode);
foreach ($this->headers as $key => $value) {
header("{$key}: {$value}");
}
echo $this->body;
$this->sent = true;
}
public function getBody(): string
{
return $this->body;
}
public function getStatusCode(): int
{
return $this->statusCode;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
final class Router
{
private array $routes = [];
private array $namedRoutes = [];
public function addRoute(string $method, string $path, string $handler, array $middleware = [], ?string $permission = null, ?string $name = null): void
{
$route = [
'method' => strtoupper($method),
'path' => $path,
'handler' => $handler,
'middleware' => $middleware,
'permission' => $permission,
'name' => $name,
];
$this->routes[] = $route;
if ($name !== null) {
$this->namedRoutes[$name] = $route;
}
}
public function dispatch(Request $request): Response
{
EventBus::dispatch('router.before_dispatch', ['request' => $request]);
$method = $request->method();
$path = $request->path();
foreach ($this->routes as $route) {
if ($route['method'] !== $method) {
continue;
}
$params = $this->matchPath($route['path'], $path);
if ($params === false) {
continue;
}
$request->setRouteParams($params);
$request->setAttribute('_route', $route);
$request->setAttribute('_permission', $route['permission']);
$response = $this->runMiddleware($route['middleware'], $request, function (Request $req) use ($route, $params) {
return $this->callHandler($route['handler'], $req, $params);
});
EventBus::dispatch('router.after_dispatch', ['request' => $request, 'response' => $response]);
return $response;
}
$methodMatched = false;
foreach ($this->routes as $route) {
if ($this->matchPath($route['path'], $path) !== false) {
$methodMatched = true;
break;
}
}
if ($methodMatched) {
return $this->handleError(405, 'الطريقة غير مسموح بها');
}
return $this->handleError(404, 'الصفحة غير موجودة');
}
private function matchPath(string $routePath, string $requestPath): array|false
{
$routePath = '/' . trim($routePath, '/');
$requestPath = '/' . trim($requestPath, '/');
if ($routePath === $requestPath) {
return [];
}
$pattern = preg_replace_callback('/\{(\w+)(?::([^}]+))?\}/', function ($matches) {
$name = $matches[1];
$constraint = $matches[2] ?? '[^/]+';
return '(?P<' . $name . '>' . $constraint . ')';
}, $routePath);
$pattern = '#^' . $pattern . '$#';
if (preg_match($pattern, $requestPath, $matches)) {
$params = [];
foreach ($matches as $key => $value) {
if (is_string($key)) {
$params[$key] = $value;
}
}
return $params;
}
return false;
}
private function runMiddleware(array $middlewareList, Request $request, callable $final): Response
{
$middlewareMap = [
'csrf' => \App\Middleware\CSRFMiddleware::class,
'auth' => \App\Middleware\AuthMiddleware::class,
'permission' => \App\Middleware\PermissionMiddleware::class,
'audit' => \App\Middleware\AuditMiddleware::class,
'rate_limit' => \App\Middleware\RateLimitMiddleware::class,
'guest' => null,
];
$stack = $final;
foreach (array_reverse($middlewareList) as $name) {
$class = $middlewareMap[$name] ?? $name;
if ($class === null || !class_exists($class)) {
continue;
}
$currentStack = $stack;
$stack = function (Request $req) use ($class, $currentStack) {
$mw = new $class();
return $mw->handle($req, $currentStack);
};
}
return $stack($request);
}
private function callHandler(string $handler, Request $request, array $params): Response
{
[$controllerName, $method] = explode('@', $handler);
$controllerClass = 'App\\Modules\\' . $controllerName;
if (!class_exists($controllerClass)) {
return $this->handleError(500, "Controller not found: {$controllerClass}");
}
$controller = new $controllerClass($request);
if (!method_exists($controller, $method)) {
return $this->handleError(500, "Method not found: {$controllerClass}@{$method}");
}
$result = $controller->$method($request, ...array_values($params));
if ($result instanceof Response) {
return $result;
}
$response = new Response();
if (is_string($result)) {
return $response->html($result);
}
if (is_array($result)) {
return $response->json($result);
}
return $response->html((string) $result);
}
private function handleError(int $code, string $message): Response
{
$response = new Response();
$app = App::getInstance();
if ($app->isDebug()) {
return $response->html(
'<html dir="rtl" lang="ar"><head><meta charset="utf-8"><title>خطأ ' . $code . '</title>'
. '<style>body{font-family:Cairo,sans-serif;text-align:center;padding:60px;background:#f9fafb;}'
. 'h1{color:#DC2626;font-size:72px;margin-bottom:10px;}p{color:#6B7280;font-size:18px;}</style></head>'
. '<body><h1>' . $code . '</h1><p>' . htmlspecialchars($message) . '</p></body></html>',
$code
);
}
$msg = $code === 404 ? 'الصفحة المطلوبة غير موجودة' : 'حدث خطأ في الخادم';
return $response->html(
'<html dir="rtl" lang="ar"><head><meta charset="utf-8"><title>خطأ</title>'
. '<style>body{font-family:Cairo,sans-serif;text-align:center;padding:60px;background:#f9fafb;}'
. 'h1{color:#DC2626;font-size:72px;margin-bottom:10px;}p{color:#6B7280;font-size:18px;}</style></head>'
. '<body><h1>' . $code . '</h1><p>' . $msg . '</p></body></html>',
$code
);
}
public static function url(string $name, array $params = []): string
{
$app = App::getInstance();
$router = $app->router();
if (!isset($router->namedRoutes[$name])) {
return '#';
}
$path = $router->namedRoutes[$name]['path'];
foreach ($params as $key => $value) {
$path = preg_replace('/\{' . $key . '(?::[^}]+)?\}/', (string) $value, $path);
}
return $app->config('app.url', '') . '/' . ltrim($path, '/');
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core\Seeder;
use App\Core\Database;
use App\Core\Autoloader;
final class SeederRunner
{
private Database $db;
public function __construct(Database $db)
{
$this->db = $db;
}
public function seedAll(): array
{
$this->ensureSeedsTable();
$ran = $this->getRanSeeds();
$files = $this->getSeedFiles();
$results = [];
foreach ($files as $file) {
$name = basename($file, '.php');
if (in_array($name, $ran)) {
continue;
}
require_once $file;
$fnName = str_replace(['-', '.'], '_', $name);
if (function_exists($fnName)) {
($fnName)($this->db);
} else {
$seed = require $file;
if (is_callable($seed)) {
$seed($this->db);
}
}
$this->db->insert('seeds', [
'seed' => $name,
'executed_at' => date('Y-m-d H:i:s'),
]);
$results[] = $name;
}
return $results;
}
public function runOne(string $name): bool
{
$this->ensureSeedsTable();
$file = $this->getSeedDir() . '/' . $name . '.php';
if (!file_exists($file)) {
return false;
}
$seed = require $file;
if (is_callable($seed)) {
$seed($this->db);
}
$ran = $this->getRanSeeds();
if (!in_array($name, $ran)) {
$this->db->insert('seeds', [
'seed' => $name,
'executed_at' => date('Y-m-d H:i:s'),
]);
}
return true;
}
private function ensureSeedsTable(): void
{
if (!$this->db->tableExists('seeds')) {
$this->db->raw("
CREATE TABLE `seeds` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`seed` VARCHAR(255) NOT NULL,
`executed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uq_seeds_name` (`seed`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
}
private function getRanSeeds(): array
{
$rows = $this->db->select("SELECT seed FROM seeds ORDER BY id");
return array_column($rows, 'seed');
}
private function getSeedFiles(): array
{
$dir = $this->getSeedDir();
$files = glob($dir . '/Phase_*.php');
if ($files === false) {
return [];
}
sort($files);
return $files;
}
private function getSeedDir(): string
{
return Autoloader::basePath() . '/database/seeds';
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
final class Session
{
private int $lifetime;
public function __construct(int $lifetimeMinutes = 30)
{
$this->lifetime = $lifetimeMinutes * 60;
}
public function start(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
if (php_sapi_name() === 'cli') {
return;
}
ini_set('session.gc_maxlifetime', (string) $this->lifetime);
ini_set('session.cookie_httponly', '1');
ini_set('session.use_strict_mode', '1');
$storagePath = Autoloader::basePath() . '/storage/sessions';
if (is_dir($storagePath) && is_writable($storagePath)) {
ini_set('session.save_path', $storagePath);
}
session_start();
$this->processFlash();
}
private function processFlash(): void
{
$previousFlash = $_SESSION['_flash_keys'] ?? [];
foreach ($previousFlash as $key) {
unset($_SESSION[$key]);
}
$_SESSION['_flash_keys'] = $_SESSION['_next_flash_keys'] ?? [];
$_SESSION['_next_flash_keys'] = [];
}
public function get(string $key, $default = null)
{
return $_SESSION[$key] ?? $default;
}
public function set(string $key, $value): void
{
$_SESSION[$key] = $value;
}
public function has(string $key): bool
{
return isset($_SESSION[$key]);
}
public function remove(string $key): void
{
unset($_SESSION[$key]);
}
public function flash(string $key, $value): void
{
$_SESSION[$key] = $value;
$flashKeys = $_SESSION['_next_flash_keys'] ?? [];
if (!in_array($key, $flashKeys)) {
$flashKeys[] = $key;
}
$_SESSION['_next_flash_keys'] = $flashKeys;
}
public function getFlash(string $key, $default = null)
{
$value = $_SESSION[$key] ?? $default;
return $value;
}
public function getAlerts(): array
{
$alerts = $this->get('_alerts', []);
return $alerts;
}
public function regenerate(): void
{
if (php_sapi_name() !== 'cli') {
session_regenerate_id(true);
}
}
public function destroy(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$params['path'], $params['domain'],
$params['secure'], $params['httponly']
);
}
if (php_sapi_name() !== 'cli') {
session_destroy();
}
}
public function id(): string
{
return session_id() ?: '';
}
public function all(): array
{
return $_SESSION ?? [];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
final class Template
{
private ?string $layoutPath = null;
private array $sections = [];
private ?string $currentSection = null;
private array $stacks = [];
private ?string $currentStack = null;
public function render(string $viewPath, array $data = []): string
{
$file = $this->resolveViewPath($viewPath);
if (!file_exists($file)) {
throw new \RuntimeException("View not found: {$viewPath} (looked at {$file})");
}
$data['app'] = App::getInstance();
$content = $this->renderFile($file, $data);
if ($this->layoutPath !== null) {
$layoutFile = $this->resolveViewPath($this->layoutPath);
if (!file_exists($layoutFile)) {
throw new \RuntimeException("Layout not found: {$this->layoutPath}");
}
$this->sections['__content__'] = $content;
$content = $this->renderFile($layoutFile, $data);
$this->layoutPath = null;
}
return $content;
}
private function renderFile(string $file, array $data): string
{
extract($data);
$__template = $this;
ob_start();
require $file;
return ob_get_clean();
}
private function resolveViewPath(string $path): string
{
$base = Autoloader::basePath() . '/app/';
$parts = explode('.', $path);
if ($parts[0] === 'Shared' || $parts[0] === 'Layout') {
return $base . 'Shared/' . implode('/', $parts) . '.php';
}
return $base . 'Modules/' . implode('/', $parts) . '.php';
}
public function layout(string $layoutPath): void
{
$this->layoutPath = $layoutPath;
}
public function section(string $name): void
{
$this->currentSection = $name;
ob_start();
}
public function endSection(): void
{
if ($this->currentSection !== null) {
$this->sections[$this->currentSection] = ob_get_clean();
$this->currentSection = null;
}
}
public function yield(string $name, string $default = ''): string
{
return $this->sections[$name] ?? $default;
}
public function include(string $path, array $data = []): void
{
$file = $this->resolveViewPath($path);
if (file_exists($file)) {
$data['app'] = App::getInstance();
$data['__template'] = $this;
extract($data);
require $file;
}
}
public function includeIf(string $path, array $data = []): void
{
$file = $this->resolveViewPath($path);
if (file_exists($file)) {
$this->include($path, $data);
}
}
public function push(string $name): void
{
$this->currentStack = $name;
ob_start();
}
public function endPush(): void
{
if ($this->currentStack !== null) {
$content = ob_get_clean();
if (!isset($this->stacks[$this->currentStack])) {
$this->stacks[$this->currentStack] = '';
}
$this->stacks[$this->currentStack] .= $content;
$this->currentStack = null;
}
}
public function stack(string $name): string
{
return $this->stacks[$name] ?? '';
}
public function e(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8');
}
public function json($data): string
{
return htmlspecialchars(
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT),
ENT_QUOTES,
'UTF-8'
);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Core;
use App\Core\Registries\ValidatorRegistry;
final class Validator
{
private array $messages = [
'required' => 'حقل :field مطلوب',
'string' => 'حقل :field يجب أن يكون نصاً',
'integer' => 'حقل :field يجب أن يكون رقماً صحيحاً',
'numeric' => 'حقل :field يجب أن يكون رقماً',
'email' => 'حقل :field يجب أن يكون بريد إلكتروني صالح',
'date' => 'حقل :field يجب أن يكون تاريخاً صالحاً',
'min' => 'حقل :field يجب أن يكون على الأقل :param',
'max' => 'حقل :field يجب ألا يزيد عن :param',
'in' => 'قيمة حقل :field غير صالحة',
'unique' => 'قيمة حقل :field مستخدمة بالفعل',
'exists' => 'قيمة حقل :field غير موجودة',
'digits' => 'حقل :field يجب أن يكون :param رقم',
'phone_eg' => 'حقل :field يجب أن يكون رقم هاتف مصري صالح',
'national_id' => 'حقل :field يجب أن يكون رقم قومي مصري صالح',
'confirmed' => 'حقل :field غير متطابق مع التأكيد',
'regex' => 'صيغة حقل :field غير صالحة',
'between' => 'حقل :field يجب أن يكون بين :param',
'file' => 'حقل :field يجب أن يكون ملفاً',
'file_max' => 'حجم الملف يجب ألا يتجاوز :param كيلوبايت',
'file_types' => 'نوع الملف غير مسموح به',
'arabic_text' => 'حقل :field يجب أن يحتوي على نص عربي فقط',
'english_text' => 'حقل :field يجب أن يحتوي على نص إنجليزي فقط',
'date_before' => 'حقل :field يجب أن يكون قبل :param',
'date_after' => 'حقل :field يجب أن يكون بعد :param',
'date_before_today' => 'حقل :field يجب أن يكون قبل اليوم',
'date_after_today' => 'حقل :field يجب أن يكون بعد اليوم',
];
public function validate(array $data, array $rules): ValidationResult
{
$errors = [];
$validated = [];
foreach ($rules as $field => $ruleString) {
$ruleList = is_array($ruleString) ? $ruleString : explode('|', $ruleString);
$value = $data[$field] ?? null;
$fieldErrors = [];
$isNullable = in_array('nullable', $ruleList);
if ($isNullable && ($value === null || $value === '')) {
$validated[$field] = $value;
continue;
}
foreach ($ruleList as $rule) {
if ($rule === 'nullable') continue;
$param = null;
if (str_contains($rule, ':')) {
[$rule, $param] = explode(':', $rule, 2);
}
$error = $this->checkRule($rule, $field, $value, $param, $data);
if ($error !== null) {
$fieldErrors[] = $error;
if ($rule === 'required') break;
}
}
if (empty($fieldErrors)) {
$validated[$field] = $value;
} else {
$errors[$field] = $fieldErrors;
}
}
return new ValidationResult($errors, $validated);
}
private function checkRule(string $rule, string $field, $value, ?string $param, array $allData): ?string
{
$customCheck = ValidatorRegistry::get($rule);
if ($customCheck !== null) {
$result = ($customCheck['handler'])($value, $param, $allData, $field);
if ($result !== true) {
return is_string($result) ? $result : $this->msg($rule, $field, $param);
}
return null;
}
return match ($rule) {
'required' => ($value === null || $value === '' || $value === []) ? $this->msg('required', $field) : null,
'string' => is_string($value) ? null : $this->msg('string', $field),
'integer' => (is_numeric($value) && (int) $value == $value) ? null : $this->msg('integer', $field),
'numeric' => is_numeric($value) ? null : $this->msg('numeric', $field),
'email' => filter_var($value, FILTER_VALIDATE_EMAIL) !== false ? null : $this->msg('email', $field),
'date' => $this->isValidDate($value) ? null : $this->msg('date', $field),
'min' => $this->checkMin($value, $param, $field),
'max' => $this->checkMax($value, $param, $field),
'between' => $this->checkBetween($value, $param, $field),
'in' => in_array($value, explode(',', $param ?? '')) ? null : $this->msg('in', $field),
'not_in' => !in_array($value, explode(',', $param ?? '')) ? null : $this->msg('in', $field),
'digits' => (is_string($value) && preg_match('/^\d{' . $param . '}$/', $value)) ? null : $this->msg('digits', $field, $param),
'digits_between' => $this->checkDigitsBetween($value, $param, $field),
'regex' => preg_match('/' . $param . '/', (string) $value) ? null : $this->msg('regex', $field),
'confirmed' => ($value === ($allData[$field . '_confirmation'] ?? null)) ? null : $this->msg('confirmed', $field),
'unique' => $this->checkUnique($value, $param, $allData, $field),
'exists' => $this->checkExists($value, $param, $field),
'phone_eg' => preg_match('/^01[0-9]{9}$/', (string) $value) ? null : $this->msg('phone_eg', $field),
'national_id' => $this->checkNationalId($value, $field),
'arabic_text' => preg_match('/^[\p{Arabic}\s\d\-\.]+$/u', (string) $value) ? null : $this->msg('arabic_text', $field),
'english_text' => preg_match('/^[a-zA-Z\s\d\-\.]+$/', (string) $value) ? null : $this->msg('english_text', $field),
'date_before_today' => ($this->isValidDate($value) && $value < date('Y-m-d')) ? null : $this->msg('date_before_today', $field),
'date_after_today' => ($this->isValidDate($value) && $value > date('Y-m-d')) ? null : $this->msg('date_after_today', $field),
'date_before' => ($this->isValidDate($value) && isset($allData[$param]) && $value < $allData[$param]) ? null : $this->msg('date_before', $field, $param),
'date_after' => ($this->isValidDate($value) && isset($allData[$param]) && $value > $allData[$param]) ? null : $this->msg('date_after', $field, $param),
'array' => is_array($value) ? null : 'حقل ' . $field . ' يجب أن يكون مصفوفة',
'json' => (json_decode((string) $value) !== null) ? null : 'حقل ' . $field . ' يجب أن يكون JSON صالح',
'file' => (is_array($value) && isset($value['tmp_name'])) ? null : $this->msg('file', $field),
'file_max' => $this->checkFileMax($value, $param, $field),
'file_types' => $this->checkFileTypes($value, $param, $field),
'decimal' => $this->checkDecimal($value, $param, $field),
default => null,
};
}
private function msg(string $rule, string $field, ?string $param = null): string
{
$msg = $this->messages[$rule] ?? 'حقل :field غير صالح';
$msg = str_replace(':field', $field, $msg);
if ($param !== null) {
$msg = str_replace(':param', $param, $msg);
}
return $msg;
}
private function isValidDate($value): bool
{
if (!is_string($value)) return false;
$d = \DateTime::createFromFormat('Y-m-d', $value);
return $d && $d->format('Y-m-d') === $value;
}
private function checkMin($value, ?string $param, string $field): ?string
{
$min = (int) $param;
if (is_string($value) && mb_strlen($value) < $min) {
return $this->msg('min', $field, $param);
}
if (is_numeric($value) && $value < $min) {
return $this->msg('min', $field, $param);
}
return null;
}
private function checkMax($value, ?string $param, string $field): ?string
{
$max = (int) $param;
if (is_string($value) && mb_strlen($value) > $max) {
return $this->msg('max', $field, $param);
}
if (is_numeric($value) && $value > $max) {
return $this->msg('max', $field, $param);
}
return null;
}
private function checkBetween($value, ?string $param, string $field): ?string
{
[$min, $max] = explode(',', $param ?? '0,0');
if (is_numeric($value) && ($value < (int)$min || $value > (int)$max)) {
return $this->msg('between', $field, $param);
}
return null;
}
private function checkDigitsBetween($value, ?string $param, string $field): ?string
{
[$min, $max] = explode(',', $param ?? '0,0');
$len = strlen((string) $value);
if (!ctype_digit((string) $value) || $len < (int) $min || $len > (int) $max) {
return $this->msg('digits', $field, $param);
}
return null;
}
private function checkUnique($value, ?string $param, array $allData, string $field): ?string
{
$parts = explode(',', $param ?? '');
$table = $parts[0] ?? '';
$column = $parts[1] ?? $field;
$exceptId = $parts[2] ?? ($allData['id'] ?? null);
$db = App::getInstance()->db();
$sql = "SELECT COUNT(*) as cnt FROM `{$table}` WHERE `{$column}` = ?";
$params = [$value];
if ($exceptId) {
$sql .= " AND `id` != ?";
$params[] = $exceptId;
}
$result = $db->selectOne($sql, $params);
return ($result['cnt'] ?? 0) > 0 ? $this->msg('unique', $field) : null;
}
private function checkExists($value, ?string $param, string $field): ?string
{
[$table, $column] = explode(',', $param ?? ',');
$column = $column ?: $field;
$db = App::getInstance()->db();
$result = $db->selectOne("SELECT COUNT(*) as cnt FROM `{$table}` WHERE `{$column}` = ?", [$value]);
return ($result['cnt'] ?? 0) === 0 ? $this->msg('exists', $field) : null;
}
private function checkNationalId($value, string $field): ?string
{
if (!is_string($value) || !preg_match('/^\d{14}$/', $value)) {
return $this->msg('national_id', $field);
}
$century = (int) $value[0];
if (!in_array($century, [2, 3])) {
return $this->msg('national_id', $field);
}
$month = (int) substr($value, 3, 2);
$day = (int) substr($value, 5, 2);
$year = ($century === 2 ? 1900 : 2000) + (int) substr($value, 1, 2);
if (!checkdate($month, $day, $year)) {
return $this->msg('national_id', $field);
}
return null;
}
private function checkFileMax($value, ?string $param, string $field): ?string
{
if (!is_array($value) || !isset($value['size'])) return null;
$maxKb = (int) $param;
if ($value['size'] > $maxKb * 1024) {
return $this->msg('file_max', $field, $param);
}
return null;
}
private function checkFileTypes($value, ?string $param, string $field): ?string
{
if (!is_array($value) || !isset($value['name'])) return null;
$allowedTypes = explode(',', $param ?? '');
$ext = strtolower(pathinfo($value['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allowedTypes)) {
return $this->msg('file_types', $field);
}
return null;
}
private function checkDecimal($value, ?string $param, string $field): ?string
{
$places = (int) ($param ?? 2);
$pattern = '/^\d+(\.\d{1,' . $places . '})?$/';
if (!preg_match($pattern, (string) $value)) {
return 'حقل ' . $field . ' يجب أن يكون رقماً عشرياً بحد أقصى ' . $places . ' خانات';
}
return null;
}
}
final class ValidationResult
{
private array $errors;
private array $validated;
public function __construct(array $errors, array $validated)
{
$this->errors = $errors;
$this->validated = $validated;
}
public function passes(): bool
{
return empty($this->errors);
}
public function fails(): bool
{
return !empty($this->errors);
}
public function errors(): array
{
return $this->errors;
}
public function validated(): array
{
return $this->validated;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\Middleware\MiddlewareInterface;
use App\Core\Request;
use App\Core\Response;
use App\Core\CSRF;
final class CSRFMiddleware implements MiddlewareInterface
{
public function handle(Request $request, callable $next): Response
{
if (in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'])) {
$token = $request->post('_csrf_token') ?? $request->header('X-CSRF-TOKEN');
if ($token === null || !CSRF::validate($token)) {
if ($request->isAjax()) {
return (new Response())->json(['error' => 'CSRF token mismatch'], 419);
}
return (new Response())->html(
'<html dir="rtl" lang="ar"><head><meta charset="utf-8"><title>خطأ أمني</title>'
. '<style>body{font-family:Cairo,sans-serif;text-align:center;padding:60px;}'
. 'h1{color:#DC2626;}</style></head>'
. '<body><h1>419</h1><p>انتهت صلاحية الجلسة. يرجى إعادة تحميل الصفحة.</p>'
. '<a href="javascript:location.reload()">إعادة تحميل</a></body></html>',
419
);
}
}
return $next($request);
}
}
\ No newline at end of file
<?php
$session = App::getInstance()->session();
$alerts = $session->getAlerts();
?>
<?php if (!empty($alerts)): ?>
<div class="alerts-wrapper">
<?php foreach ($alerts as $alert): ?>
<div class="alert alert-<?= e($alert['type'] ?? 'info') ?>" data-auto-dismiss="5000">
<span class="alert-message"><?= e($alert['message'] ?? '') ?></span>
<button class="alert-close" onclick="this.parentElement.remove()"></button>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
\ No newline at end of file
<?php
/**
* @var array $items [['label'=>..., 'url'=>...]]
*/
$items = $items ?? [];
?>
<?php if (!empty($items)): ?>
<nav class="breadcrumbs">
<a href="/" class="breadcrumb-item">🏠 الرئيسية</a>
<?php foreach ($items as $i => $item): ?>
<span class="breadcrumb-separator"></span>
<?php if ($i < count($items) - 1 && !empty($item['url'])): ?>
<a href="<?= e($item['url']) ?>" class="breadcrumb-item"><?= e($item['label']) ?></a>
<?php else: ?>
<span class="breadcrumb-item current"><?= e($item['label']) ?></span>
<?php endif; ?>
<?php endforeach; ?>
</nav>
<?php endif; ?>
\ No newline at end of file
<?php
/**
* @var array $columns [['key'=>..., 'label_ar'=>..., 'sortable'=>bool, 'class'=>'']]
* @var array $rows
* @var array $pagination (optional)
* @var array $actions (optional) per-row actions
*/
$columns = $columns ?? [];
$rows = $rows ?? [];
$pagination = $pagination ?? null;
$actions = $actions ?? [];
?>
<?php if (empty($rows)): ?>
<?php $__template->include('Shared.Components.empty-state', ['message' => $emptyMessage ?? 'لا توجد بيانات']); ?>
<?php else: ?>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<?php foreach ($columns as $col): ?>
<th class="<?= e($col['class'] ?? '') ?>">
<?= e($col['label_ar'] ?? $col['key']) ?>
</th>
<?php endforeach; ?>
<?php if (!empty($actions)): ?>
<th class="actions-col">الإجراءات</th>
<?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row): ?>
<tr>
<?php foreach ($columns as $col): ?>
<td class="<?= e($col['class'] ?? '') ?>">
<?php
$key = $col['key'];
$val = $row[$key] ?? '';
if (isset($col['render']) && is_callable($col['render'])) {
echo ($col['render'])($val, $row);
} else {
echo e((string) $val);
}
?>
</td>
<?php endforeach; ?>
<?php if (!empty($actions)): ?>
<td class="actions-col">
<div class="action-buttons">
<?php foreach ($actions as $action): ?>
<?php
$href = $action['href'] ?? '#';
if (is_callable($href)) $href = $href($row);
$label = $action['label'] ?? '';
$class = $action['class'] ?? 'btn btn-sm btn-outline';
?>
<a href="<?= e($href) ?>" class="<?= e($class) ?>"><?= e($label) ?></a>
<?php endforeach; ?>
</div>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ($pagination): ?>
<?php $__template->include('Shared.Components.pagination', ['pagination' => $pagination]); ?>
<?php endif; ?>
<?php endif; ?>
\ No newline at end of file
<?php
$message = $message ?? 'لا توجد بيانات لعرضها';
$icon = $icon ?? '📭';
$actionUrl = $actionUrl ?? null;
$actionLabel = $actionLabel ?? null;
?>
<div class="empty-state">
<div class="empty-state-icon"><?= $icon ?></div>
<p class="empty-state-message"><?= e($message) ?></p>
<?php if ($actionUrl && $actionLabel): ?>
<a href="<?= e($actionUrl) ?>" class="btn btn-primary"><?= e($actionLabel) ?></a>
<?php endif; ?>
</div>
\ No newline at end of file
<?php
/**
* Renders a form field.
* @var string $type
* @var string $name
* @var string $label_ar
* @var mixed $value
* @var bool $required
* @var bool $disabled
* @var string $error
* @var array $options (for select/radio)
* @var string $placeholder
* @var string $help_text
*/
$type = $type ?? 'text';
$name = $name ?? '';
$label_ar = $label_ar ?? '';
$value = $value ?? old($name, $default ?? '');
$required = $required ?? false;
$disabled = $disabled ?? false;
$errors = App::getInstance()->session()->get('_errors', []);
$fieldErrors = $errors[$name] ?? [];
$error = $error ?? (is_array($fieldErrors) ? ($fieldErrors[0] ?? '') : $fieldErrors);
$options = $options ?? [];
$placeholder = $placeholder ?? '';
$help_text = $help_text ?? '';
$id = 'field_' . str_replace(['[', ']', '.'], '_', $name);
?>
<div class="form-group <?= $error ? 'has-error' : '' ?>">
<?php if ($label_ar && $type !== 'hidden'): ?>
<label for="<?= e($id) ?>" class="form-label">
<?= e($label_ar) ?>
<?php if ($required): ?><span class="required-mark">*</span><?php endif; ?>
</label>
<?php endif; ?>
<?php if ($type === 'text' || $type === 'email' || $type === 'phone' || $type === 'number' || $type === 'password'): ?>
<input type="<?= $type === 'phone' ? 'tel' : e($type) ?>"
id="<?= e($id) ?>"
name="<?= e($name) ?>"
value="<?= e((string) $value) ?>"
placeholder="<?= e($placeholder) ?>"
class="form-input"
<?= $required ? 'required' : '' ?>
<?= $disabled ? 'disabled' : '' ?>
>
<?php elseif ($type === 'national_id'): ?>
<input type="text"
id="<?= e($id) ?>"
name="<?= e($name) ?>"
value="<?= e((string) $value) ?>"
placeholder="أدخل الرقم القومي (14 رقم)"
class="form-input nid-input"
maxlength="14"
pattern="\d{14}"
data-nid-parser="true"
<?= $required ? 'required' : '' ?>
<?= $disabled ? 'disabled' : '' ?>
>
<?php elseif ($type === 'date'): ?>
<input type="date"
id="<?= e($id) ?>"
name="<?= e($name) ?>"
value="<?= e((string) $value) ?>"
class="form-input"
<?= $required ? 'required' : '' ?>
<?= $disabled ? 'disabled' : '' ?>
>
<?php elseif ($type === 'select'): ?>
<select id="<?= e($id) ?>" name="<?= e($name) ?>" class="form-select" <?= $required ? 'required' : '' ?> <?= $disabled ? 'disabled' : '' ?>>
<option value="">-- اختر --</option>
<?php foreach ($options as $optVal => $optLabel): ?>
<option value="<?= e((string) $optVal) ?>" <?= (string) $value === (string) $optVal ? 'selected' : '' ?>><?= e($optLabel) ?></option>
<?php endforeach; ?>
</select>
<?php elseif ($type === 'textarea'): ?>
<textarea id="<?= e($id) ?>" name="<?= e($name) ?>" class="form-textarea" placeholder="<?= e($placeholder) ?>" <?= $required ? 'required' : '' ?> <?= $disabled ? 'disabled' : '' ?>><?= e((string) $value) ?></textarea>
<?php elseif ($type === 'file'): ?>
<input type="file" id="<?= e($id) ?>" name="<?= e($name) ?>" class="form-input" <?= $required ? 'required' : '' ?>>
<?php elseif ($type === 'checkbox'): ?>
<label class="checkbox-label">
<input type="checkbox" name="<?= e($name) ?>" value="1" <?= $value ? 'checked' : '' ?> <?= $disabled ? 'disabled' : '' ?>>
<span><?= e($label_ar) ?></span>
</label>
<?php elseif ($type === 'radio'): ?>
<div class="radio-group">
<?php foreach ($options as $optVal => $optLabel): ?>
<label class="radio-label">
<input type="radio" name="<?= e($name) ?>" value="<?= e((string) $optVal) ?>" <?= (string) $value === (string) $optVal ? 'checked' : '' ?> <?= $disabled ? 'disabled' : '' ?>>
<span><?= e($optLabel) ?></span>
</label>
<?php endforeach; ?>
</div>
<?php elseif ($type === 'hidden'): ?>
<input type="hidden" name="<?= e($name) ?>" value="<?= e((string) $value) ?>">
<?php endif; ?>
<?php if ($error): ?>
<div class="form-error"><?= e($error) ?></div>
<?php endif; ?>
<?php if ($help_text): ?>
<small class="form-help"><?= e($help_text) ?></small>
<?php endif; ?>
</div>
\ No newline at end of file
<?php
use App\Core\App;
$employee = App::getInstance()->currentEmployee();
$branch = App::getInstance()->currentBranch();
?>
<div class="topbar-right">
<button class="topbar-btn sidebar-mobile-toggle" onclick="toggleSidebar()"></button>
<div class="topbar-search">
<input type="text" id="global-search" placeholder="بحث سريع..." class="topbar-search-input" autocomplete="off">
</div>
</div>
<div class="topbar-left">
<span class="topbar-date"><?= arabic_date(today()) ?></span>
<?php if ($branch): ?>
<span class="topbar-branch"><?= e($branch['name_ar'] ?? '') ?></span>
<?php endif; ?>
<div class="topbar-notifications">
<button class="topbar-btn" id="notif-bell" title="الإشعارات">
🔔 <span class="notif-badge" id="notif-badge" style="display:none;">0</span>
</button>
</div>
<?php if ($employee): ?>
<div class="topbar-user">
<span class="topbar-username"><?= e($employee->full_name_ar ?? ($employee['full_name_ar'] ?? 'مستخدم')) ?></span>
<a href="/logout" class="topbar-btn topbar-logout" title="تسجيل الخروج">خروج</a>
</div>
<?php endif; ?>
</div>
\ No newline at end of file
<?php
/**
* @var string $id
* @var string $title
* @var string $size (small|medium|large|fullscreen)
*/
$id = $id ?? 'modal';
$title = $title ?? '';
$size = $size ?? 'medium';
?>
<div class="modal-overlay" id="<?= e($id) ?>" style="display:none;">
<div class="modal modal-<?= e($size) ?>">
<div class="modal-header">
<h3 class="modal-title"><?= e($title) ?></h3>
<button class="modal-close" onclick="closeModal('<?= e($id) ?>')"></button>
</div>
<div class="modal-body">
<?= $__template->yield('modal_body_' . $id, '') ?>
</div>
<div class="modal-footer">
<?= $__template->yield('modal_footer_' . $id, '') ?>
</div>
</div>
</div>
\ No newline at end of file
<?php
/**
* @var array $pagination
* @var string $baseUrl (optional)
*/
$pagination = $pagination ?? [];
if (empty($pagination) || ($pagination['last_page'] ?? 1) <= 1) return;
$baseUrl = $baseUrl ?? '?';
$separator = str_contains($baseUrl, '?') ? '&' : '?';
?>
<nav class="pagination-wrapper">
<div class="pagination-info">
عرض <?= $pagination['from'] ?> إلى <?= $pagination['to'] ?> من <?= $pagination['total'] ?> نتيجة
</div>
<ul class="pagination">
<?php if ($pagination['has_prev']): ?>
<li><a href="<?= $baseUrl . $separator ?>page=<?= $pagination['prev_page'] ?>" class="page-link">السابق</a></li>
<?php endif; ?>
<?php foreach ($pagination['pages'] as $page): ?>
<?php if ($page === '...'): ?>
<li class="page-ellipsis">...</li>
<?php else: ?>
<li>
<a href="<?= $baseUrl . $separator ?>page=<?= $page ?>"
class="page-link <?= $page === $pagination['current_page'] ? 'active' : '' ?>">
<?= $page ?>
</a>
</li>
<?php endif; ?>
<?php endforeach; ?>
<?php if ($pagination['has_next']): ?>
<li><a href="<?= $baseUrl . $separator ?>page=<?= $pagination['next_page'] ?>" class="page-link">التالي</a></li>
<?php endif; ?>
</ul>
</nav>
\ No newline at end of file
<?php
use App\Core\Registries\MenuRegistry;
use App\Core\App;
$employee = App::getInstance()->currentEmployee();
$permissions = ['*'];
if ($employee && method_exists($employee, 'getAllPermissions')) {
$permissions = $employee->getAllPermissions();
}
$menuItems = MenuRegistry::getVisible($permissions);
$currentPath = $_SERVER['REQUEST_URI'] ?? '/';
$currentPath = parse_url($currentPath, PHP_URL_PATH);
?>
<div class="sidebar-header">
<div class="sidebar-logo">
<span class="sidebar-brand">THE CLUB</span>
</div>
<button class="sidebar-toggle" id="sidebar-toggle" onclick="toggleSidebar()"></button>
</div>
<nav class="sidebar-nav">
<ul class="sidebar-menu">
<li class="sidebar-item">
<a href="/" class="sidebar-link <?= $currentPath === '/' ? 'active' : '' ?>">
<span class="sidebar-icon">🏠</span>
<span class="sidebar-text">الرئيسية</span>
</a>
</li>
<?php foreach ($menuItems as $key => $item): ?>
<?php
$hasChildren = !empty($item['children']);
$isActive = str_starts_with($currentPath, $item['route'] ?? '###');
if ($hasChildren) {
foreach ($item['children'] as $child) {
if (str_starts_with($currentPath, $child['route'] ?? '###')) {
$isActive = true;
break;
}
}
}
?>
<li class="sidebar-item <?= $hasChildren ? 'has-children' : '' ?> <?= $isActive ? 'open' : '' ?>">
<?php if ($hasChildren): ?>
<a href="#" class="sidebar-link <?= $isActive ? 'active' : '' ?>" onclick="toggleSubmenu(this);return false;">
<span class="sidebar-icon"><?= $item['icon'] ?? '📁' ?></span>
<span class="sidebar-text"><?= e($item['label_ar'] ?? '') ?></span>
<span class="sidebar-arrow"></span>
</a>
<ul class="sidebar-submenu" style="<?= $isActive ? 'display:block;' : 'display:none;' ?>">
<?php foreach ($item['children'] as $child): ?>
<li>
<a href="<?= e($child['route'] ?? '#') ?>" class="sidebar-sublink <?= $currentPath === ($child['route'] ?? '') ? 'active' : '' ?>">
<?= e($child['label_ar'] ?? '') ?>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<a href="<?= e($item['route'] ?? '#') ?>" class="sidebar-link <?= $isActive ? 'active' : '' ?>">
<span class="sidebar-icon"><?= $item['icon'] ?? '📄' ?></span>
<span class="sidebar-text"><?= e($item['label_ar'] ?? '') ?></span>
</a>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</nav>
\ No newline at end of file
<?php
/**
* @var string $title
* @var string $value
* @var string $icon
* @var string $color (primary|success|danger|warning)
* @var string $link (optional)
* @var string $change (optional, e.g. "+5%")
*/
$color = $color ?? 'primary';
?>
<div class="stats-card stats-card-<?= e($color) ?>">
<div class="stats-card-icon"><?= $icon ?? '📊' ?></div>
<div class="stats-card-content">
<div class="stats-card-title"><?= e($title ?? '') ?></div>
<div class="stats-card-value"><?= e((string)($value ?? '0')) ?></div>
<?php if (!empty($change)): ?>
<div class="stats-card-change"><?= e($change) ?></div>
<?php endif; ?>
</div>
<?php if (!empty($link)): ?>
<a href="<?= e($link) ?>" class="stats-card-link">عرض الكل ←</a>
<?php endif; ?>
</div>
\ No newline at end of file
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="<?= \App\Core\CSRF::token() ?>">
<title><?= $__template->yield('title', 'نادي النادي شيراتون') ?></title>
<link rel="stylesheet" href="<?= url('assets/css/main.css') ?>">
<?= $__template->yield('styles', '') ?>
</head>
<body>
<aside class="sidebar" id="sidebar">
<?php $__template->include('Shared.Components.sidebar', ['app' => $app]); ?>
</aside>
<div class="main-wrapper">
<header class="topbar">
<?php $__template->include('Shared.Components.header', ['app' => $app]); ?>
</header>
<div class="content-area">
<div class="breadcrumb-area">
<?= $__template->yield('breadcrumbs', '') ?>
</div>
<div class="page-header-row">
<h1 class="page-title"><?= $__template->yield('title', '') ?></h1>
<div class="page-actions">
<?= $__template->yield('page_actions', '') ?>
</div>
</div>
<?php $__template->include('Shared.Components.alerts', ['app' => $app]); ?>
<main class="main-content">
<?= $__template->yield('content', '') ?>
</main>
</div>
<footer class="footer">
<span>نادي النادي شيراتون &copy; <?= date('Y') ?></span>
<span>الإصدار <?= e($app->config('app.version', '1.0.0')) ?></span>
<span><?= arabic_date(today()) ?></span>
</footer>
</div>
<div id="modal-container"></div>
<div id="toast-container" class="toast-container"></div>
<script src="<?= url('assets/js/app.js') ?>"></script>
<?= $__template->yield('scripts', '') ?>
<?= $__template->stack('scripts') ?>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<title><?= $__template->yield('title', 'طباعة') ?></title>
<link rel="stylesheet" href="<?= url('assets/css/main.css') ?>">
<style>
body { padding: 20px; background: #fff; }
@media print { body { padding: 0; } }
</style>
</head>
<body>
<div class="print-header" style="text-align:center;margin-bottom:20px;border-bottom:2px solid #0D7377;padding-bottom:10px;">
<h2 style="color:#0D7377;margin:0;">نادي النادي شيراتون</h2>
<p style="color:#6B7280;margin:5px 0;"><?= arabic_date(today()) ?></p>
</div>
<?= $__template->yield('content', '') ?>
<script>window.onload = function() { window.print(); }</script>
</body>
</html>
\ No newline at end of file
<?php
declare(strict_types=1);
// Load environment
$envFile = __DIR__ . '/.env';
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) continue;
if (str_contains($line, '=')) {
[$key, $val] = explode('=', $line, 2);
$_ENV[trim($key)] = trim($val);
$_SERVER[trim($key)] = trim($val);
}
}
}
require_once __DIR__ . '/app/Core/Autoloader.php';
\App\Core\Autoloader::register();
$config = new \App\Core\Config();
$config->loadAll();
$db = new \App\Core\Database(
$config->get('database.host', '127.0.0.1'),
(int) $config->get('database.port', '3306'),
$config->get('database.name', 'the_club_erp'),
$config->get('database.user', 'root'),
$config->get('database.pass', ''),
$config->get('database.charset', 'utf8mb4')
);
$command = $argv[1] ?? 'help';
echo "╔══════════════════════════════════════╗\n";
echo "║ THE CLUB ERP — CLI Tool ║\n";
echo "╚══════════════════════════════════════╝\n\n";
switch ($command) {
case 'migrate':
$runner = new \App\Core\Migration\MigrationRunner($db);
$results = $runner->migrate();
if (empty($results)) {
echo " ✓ Nothing to migrate. All up to date.\n";
} else {
foreach ($results as $m) {
echo " ✓ Migrated: {$m}\n";
}
}
break;
case 'migrate:rollback':
$runner = new \App\Core\Migration\MigrationRunner($db);
$results = $runner->rollback();
if (empty($results)) {
echo " ✓ Nothing to rollback.\n";
} else {
foreach ($results as $m) {
echo " ↩ Rolled back: {$m}\n";
}
}
break;
case 'migrate:status':
$runner = new \App\Core\Migration\MigrationRunner($db);
$status = $runner->status();
echo str_pad('Migration', 60) . "Status\n";
echo str_repeat('─', 70) . "\n";
foreach ($status as $s) {
$icon = $s['status'] === 'Ran' ? '✓' : '○';
echo " {$icon} " . str_pad($s['migration'], 58) . $s['status'] . "\n";
}
break;
case 'seed':
$runner = new \App\Core\Seeder\SeederRunner($db);
$results = $runner->seedAll();
if (empty($results)) {
echo " ✓ Nothing to seed. All seeds already ran.\n";
} else {
foreach ($results as $s) {
echo " ✓ Seeded: {$s}\n";
}
}
break;
case 'seed:run':
$name = $argv[2] ?? null;
if (!$name) {
echo " ✗ Please provide seed name.\n";
exit(1);
}
$runner = new \App\Core\Seeder\SeederRunner($db);
if ($runner->runOne($name)) {
echo " ✓ Seeded: {$name}\n";
} else {
echo " ✗ Seed not found: {$name}\n";
}
break;
case 'cron':
echo " Running background jobs...\n";
$cronRunner = __DIR__ . '/cron/runner.php';
if (file_exists($cronRunner)) {
require $cronRunner;
} else {
echo " ✗ Cron runner not found (Phase 15).\n";
}
break;
default:
echo "Available commands:\n";
echo " migrate Run all pending migrations\n";
echo " migrate:rollback Rollback last migration batch\n";
echo " migrate:status Show migration status\n";
echo " seed Run all pending seeds\n";
echo " seed:run <name> Run a specific seed\n";
echo " cron Run background jobs\n";
break;
}
echo "\nDone.\n";
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'name_ar' => 'نادي النادي شيراتون',
'name_en' => 'THE CLUB Sheraton',
'url' => env('APP_URL', 'http://localhost'),
'debug' => env('APP_DEBUG', false),
'env' => env('APP_ENV', 'production'),
'timezone' => 'Africa/Cairo',
'locale' => 'ar',
'fallback_locale' => 'en',
'per_page' => 25,
'session_lifetime' => 30,
'currency' => 'EGP',
'currency_symbol' => 'ج.م',
'date_format' => 'd/m/Y',
'datetime_format' => 'd/m/Y H:i',
'financial_year_start_month' => 7,
'financial_year_start_day' => 1,
'version' => '1.0.0',
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'name' => env('DB_NAME', 'the_club_erp'),
'user' => env('DB_USER', 'root'),
'pass' => env('DB_PASS', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'upload_max_size_kb' => 10240,
'allowed_types' => ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'doc', 'docx', 'xls', 'xlsx'],
'allowed_mime_types' => [
'application/pdf',
'image/jpeg',
'image/png',
'image/gif',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
],
'paths' => [
'documents' => 'uploads/documents',
'photos' => 'uploads/photos',
'forms' => 'uploads/forms',
],
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `migrations` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`migration` VARCHAR(255) NOT NULL,
`batch` INT UNSIGNED NOT NULL,
`executed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uq_migrations_name` (`migration`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `migrations`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `system_config` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`config_key` VARCHAR(255) NOT NULL,
`config_value` TEXT NULL,
`config_type` ENUM('string','integer','float','boolean','json') NOT NULL DEFAULT 'string',
`group_name` VARCHAR(100) NOT NULL DEFAULT 'general',
`description_ar` VARCHAR(500) NULL,
`description_en` VARCHAR(500) NULL,
`is_editable` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uq_system_config_key` (`config_key`),
INDEX `idx_system_config_group` (`group_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `system_config`",
];
\ No newline at end of file
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]
</IfModule>
\ No newline at end of file
/* ════════════════════════════════════════════════════════════
THE CLUB ERP — Complete RTL-First Design System
════════════════════════════════════════════════════════════ */
/* ── CSS Custom Properties ── */
:root {
--primary: #0D7377;
--primary-light: #14A3A8;
--primary-dark: #095355;
--bg: #FFFFFF;
--surface: #F5F7FA;
--text: #1A1A2E;
--text-secondary: #6B7280;
--border: #E5E7EB;
--success: #059669;
--warning: #D97706;
--error: #DC2626;
--info: #0284C7;
--shadow: 0 1px 3px rgba(0,0,0,0.1);
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
--radius: 8px;
--radius-sm: 4px;
--sidebar-w: 260px;
--topbar-h: 56px;
--font: 'Cairo', 'Segoe UI', Tahoma, sans-serif;
--font-mono: 'Courier New', monospace;
}
/* ── Reset & Base ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 14px; }
body { font-family: var(--font); color: var(--text); background: var(--surface); direction: rtl; line-height: 1.6; min-height: 100vh; }
a { color: var(--primary); text-decoration: none; }
a:hover { color: var(--primary-dark); }
img { max-width: 100%; }
h1 { font-size: 1.75rem; font-weight: 700; }
h2 { font-size: 1.5rem; font-weight: 700; }
h3 { font-size: 1.25rem; font-weight: 600; }
h4 { font-size: 1.1rem; font-weight: 600; }
h5 { font-size: 1rem; font-weight: 600; }
h6 { font-size: .875rem; font-weight: 600; }
/* ── Sidebar ── */
.sidebar { position: fixed; top: 0; right: 0; width: var(--sidebar-w); height: 100vh; background: var(--primary-dark); color: #fff; overflow-y: auto; z-index: 100; transition: transform .3s ease; }
.sidebar-header { display: flex; align-items: center; justify-content: space-between; padding: 16px; border-bottom: 1px solid rgba(255,255,255,.1); }
.sidebar-brand { font-size: 1.2rem; font-weight: 700; color: #fff; }
.sidebar-toggle { background: none; border: none; color: #fff; font-size: 1.2rem; cursor: pointer; display: none; }
.sidebar-nav { padding: 8px 0; }
.sidebar-menu { list-style: none; }
.sidebar-item { border-bottom: 1px solid rgba(255,255,255,.05); }
.sidebar-link { display: flex; align-items: center; padding: 10px 16px; color: rgba(255,255,255,.8); transition: all .2s; gap: 10px; }
.sidebar-link:hover, .sidebar-link.active { background: rgba(255,255,255,.1); color: #fff; }
.sidebar-icon { width: 24px; text-align: center; font-size: 1rem; }
.sidebar-text { flex: 1; }
.sidebar-arrow { font-size: .7rem; transition: transform .2s; }
.sidebar-item.open .sidebar-arrow { transform: rotate(-90deg); }
.sidebar-submenu { list-style: none; background: rgba(0,0,0,.15); }
.sidebar-sublink { display: block; padding: 8px 40px; color: rgba(255,255,255,.7); font-size: .9rem; transition: all .2s; }
.sidebar-sublink:hover, .sidebar-sublink.active { color: #fff; background: rgba(255,255,255,.08); }
/* ── Main Wrapper ── */
.main-wrapper { margin-right: var(--sidebar-w); min-height: 100vh; display: flex; flex-direction: column; }
/* ── Topbar ── */
.topbar { position: sticky; top: 0; height: var(--topbar-h); background: var(--bg); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; padding: 0 20px; z-index: 50; box-shadow: var(--shadow); }
.topbar-right, .topbar-left { display: flex; align-items: center; gap: 12px; }
.topbar-search-input { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 12px; width: 280px; font-family: var(--font); }
.topbar-btn { background: none; border: none; cursor: pointer; font-size: 1rem; padding: 4px 8px; border-radius: var(--radius-sm); color: var(--text-secondary); }
.topbar-btn:hover { background: var(--surface); color: var(--text); }
.topbar-date { color: var(--text-secondary); font-size: .85rem; }
.topbar-branch { background: var(--primary); color: #fff; padding: 2px 10px; border-radius: 12px; font-size: .8rem; }
.topbar-username { font-weight: 600; color: var(--text); }
.topbar-logout { color: var(--error) !important; font-weight: 600; }
.notif-badge { background: var(--error); color: #fff; border-radius: 50%; padding: 0 5px; font-size: .7rem; position: relative; top: -8px; }
.sidebar-mobile-toggle { display: none; }
/* ── Content Area ── */
.content-area { flex: 1; padding: 20px; }
.page-header-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.page-title { color: var(--text); margin: 0; }
.page-actions { display: flex; gap: 8px; }
/* ── Footer ── */
.footer { padding: 12px 20px; background: var(--bg); border-top: 1px solid var(--border); display: flex; justify-content: space-between; font-size: .8rem; color: var(--text-secondary); }
/* ── Cards ── */
.card { background: var(--bg); border-radius: var(--radius); box-shadow: var(--shadow); margin-bottom: 20px; overflow: hidden; }
.card-header { padding: 14px 20px; border-bottom: 1px solid var(--border); font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
.card-body { padding: 20px; }
.card-footer { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
/* ── Tables ── */
.table-responsive { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th { background: var(--primary-dark); color: #fff; padding: 10px 14px; text-align: right; font-weight: 600; white-space: nowrap; }
.data-table td { padding: 10px 14px; border-bottom: 1px solid var(--border); }
.data-table tbody tr:nth-child(even) { background: #F9FAFB; }
.data-table tbody tr:hover { background: #E6F4F4; }
.actions-col { width: 120px; text-align: center; }
.action-buttons { display: flex; gap: 4px; justify-content: center; }
/* ── Forms ── */
.form-group { margin-bottom: 16px; }
.form-label { display: block; margin-bottom: 4px; font-weight: 600; color: var(--text); font-size: .9rem; }
.required-mark { color: var(--error); }
.form-input, .form-select, .form-textarea { width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); font-family: var(--font); font-size: .95rem; color: var(--text); background: var(--bg); transition: border-color .2s; }
.form-input:focus, .form-select:focus, .form-textarea:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(13,115,119,.15); }
.form-textarea { min-height: 100px; resize: vertical; }
.form-error { color: var(--error); font-size: .8rem; margin-top: 2px; }
.form-help { color: var(--text-secondary); font-size: .8rem; margin-top: 2px; }
.has-error .form-input, .has-error .form-select { border-color: var(--error); }
.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; }
.checkbox-label, .radio-label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
.radio-group { display: flex; gap: 16px; flex-wrap: wrap; }
/* ── Buttons ── */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 18px; border: 1px solid transparent; border-radius: var(--radius-sm); font-family: var(--font); font-size: .9rem; font-weight: 600; cursor: pointer; transition: all .2s; text-decoration: none; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-dark); color: #fff; }
.btn-secondary { background: var(--surface); color: var(--text); border-color: var(--border); }
.btn-secondary:hover { background: var(--border); }
.btn-danger { background: var(--error); color: #fff; }
.btn-danger:hover { background: #B91C1C; color: #fff; }
.btn-success { background: var(--success); color: #fff; }
.btn-success:hover { background: #047857; color: #fff; }
.btn-warning { background: var(--warning); color: #fff; }
.btn-outline { background: transparent; color: var(--primary); border-color: var(--primary); }
.btn-outline:hover { background: var(--primary); color: #fff; }
.btn-sm { padding: 4px 10px; font-size: .8rem; }
.btn-lg { padding: 12px 28px; font-size: 1rem; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
/* ── Alerts ── */
.alerts-wrapper { margin-bottom: 16px; }
.alert { padding: 12px 16px; border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; animation: slideIn .3s ease; }
.alert-success { background: #ECFDF5; color: #065F46; border: 1px solid #A7F3D0; }
.alert-error { background: #FEF2F2; color: #991B1B; border: 1px solid #FECACA; }
.alert-warning { background: #FFFBEB; color: #92400E; border: 1px solid #FDE68A; }
.alert-info { background: #EFF6FF; color: #1E40AF; border: 1px solid #BFDBFE; }
.alert-close { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
.alert-close:hover { opacity: 1; }
/* ── Badges ── */
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: .75rem; font-weight: 600; }
.badge-primary { background: #E0F2F1; color: var(--primary-dark); }
.badge-success { background: #ECFDF5; color: var(--success); }
.badge-danger { background: #FEF2F2; color: var(--error); }
.badge-warning { background: #FFFBEB; color: var(--warning); }
.badge-info { background: #EFF6FF; color: var(--info); }
/* ── Modals ── */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; z-index: 200; animation: fadeIn .2s; }
.modal { background: var(--bg); border-radius: var(--radius); width: 90%; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; }
.modal-small { max-width: 400px; }
.modal-medium { max-width: 600px; }
.modal-large { max-width: 900px; }
.modal-fullscreen { max-width: 95vw; max-height: 95vh; }
.modal-header { padding: 14px 20px; background: var(--primary-dark); color: #fff; display: flex; align-items: center; justify-content: space-between; }
.modal-title { font-weight: 600; }
.modal-close { background: none; border: none; color: #fff; font-size: 1.2rem; cursor: pointer; }
.modal-body { padding: 20px; overflow-y: auto; flex: 1; }
.modal-footer { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
/* ── Toast Notifications ── */
.toast-container { position: fixed; bottom: 20px; left: 20px; z-index: 300; display: flex; flex-direction: column-reverse; gap: 8px; }
.toast { padding: 12px 18px; border-radius: var(--radius-sm); color: #fff; font-weight: 500; min-width: 280px; box-shadow: var(--shadow-md); animation: slideUp .3s ease; display: flex; justify-content: space-between; align-items: center; }
.toast-success { background: var(--success); }
.toast-error { background: var(--error); }
.toast-warning { background: var(--warning); }
.toast-info { background: var(--info); }
.toast-close { background: none; border: none; color: #fff; cursor: pointer; margin-right: 10px; }
/* ── Breadcrumbs ── */
.breadcrumbs { display: flex; align-items: center; gap: 4px; margin-bottom: 10px; font-size: .85rem; flex-wrap: wrap; }
.breadcrumb-item { color: var(--text-secondary); }
.breadcrumb-item.current { color: var(--text); font-weight: 600; }
.breadcrumb-separator { color: var(--border); }
/* ── Pagination ── */
.pagination-wrapper { display: flex; align-items: center; justify-content: space-between; margin-top: 16px; flex-wrap: wrap; gap: 12px; }
.pagination-info { color: var(--text-secondary); font-size: .85rem; }
.pagination { display: flex; list-style: none; gap: 4px; }
.page-link { display: inline-block; padding: 6px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); font-size: .85rem; }
.page-link:hover { background: var(--surface); color: var(--text); }
.page-link.active { background: var(--primary); color: #fff; border-color: var(--primary); }
.page-ellipsis { padding: 6px 8px; color: var(--text-secondary); }
/* ── Stats Cards ── */
.stats-card { background: var(--bg); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px; display: flex; align-items: center; gap: 16px; position: relative; overflow: hidden; }
.stats-card::after { content: ''; position: absolute; top: 0; right: 0; width: 4px; height: 100%; }
.stats-card-primary::after { background: var(--primary); }
.stats-card-success::after { background: var(--success); }
.stats-card-danger::after { background: var(--error); }
.stats-card-warning::after { background: var(--warning); }
.stats-card-icon { font-size: 2rem; }
.stats-card-title { color: var(--text-secondary); font-size: .85rem; }
.stats-card-value { font-size: 1.5rem; font-weight: 700; color: var(--text); }
.stats-card-change { font-size: .8rem; color: var(--success); }
.stats-card-link { position: absolute; bottom: 8px; left: 16px; font-size: .8rem; }
/* ── Empty State ── */
.empty-state { text-align: center; padding: 40px; }
.empty-state-icon { font-size: 3rem; margin-bottom: 12px; }
.empty-state-message { color: var(--text-secondary); font-size: 1rem; margin-bottom: 16px; }
/* ── Tabs ── */
.tabs { display: flex; border-bottom: 2px solid var(--border); margin-bottom: 16px; gap: 0; overflow-x: auto; }
.tab-link { padding: 10px 20px; color: var(--text-secondary); font-weight: 600; border-bottom: 2px solid transparent; margin-bottom: -2px; cursor: pointer; white-space: nowrap; transition: all .2s; background: none; border-top: none; border-left: none; border-right: none; font-family: var(--font); }
.tab-link:hover { color: var(--primary); }
.tab-link.active { color: var(--primary); border-bottom-color: var(--primary); }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* ── Status Dots ── */
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-left: 6px; }
.status-dot-success { background: var(--success); }
.status-dot-warning { background: var(--warning); }
.status-dot-danger { background: var(--error); }
.status-dot-info { background: var(--info); }
/* ── Loading Spinner ── */
.spinner { display: inline-block; width: 24px; height: 24px; border: 3px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Utilities ── */
.d-none { display: none !important; }
.d-block { display: block !important; }
.d-flex { display: flex !important; }
.d-grid { display: grid !important; }
.text-right { text-align: right !important; }
.text-left { text-align: left !important; }
.text-center { text-align: center !important; }
.text-bold { font-weight: 700 !important; }
.text-muted { color: var(--text-secondary) !important; }
.text-success { color: var(--success) !important; }
.text-danger { color: var(--error) !important; }
.text-warning { color: var(--warning) !important; }
.text-primary { color: var(--primary) !important; }
.m-0 { margin: 0 !important; } .m-1 { margin: 4px !important; } .m-2 { margin: 8px !important; } .m-3 { margin: 16px !important; } .m-4 { margin: 24px !important; } .m-5 { margin: 32px !important; }
.mb-0 { margin-bottom: 0 !important; } .mb-1 { margin-bottom: 4px !important; } .mb-2 { margin-bottom: 8px !important; } .mb-3 { margin-bottom: 16px !important; } .mb-4 { margin-bottom: 24px !important; }
.mt-0 { margin-top: 0 !important; } .mt-2 { margin-top: 8px !important; } .mt-3 { margin-top: 16px !important; }
.p-0 { padding: 0 !important; } .p-1 { padding: 4px !important; } .p-2 { padding: 8px !important; } .p-3 { padding: 16px !important; } .p-4 { padding: 24px !important; } .p-5 { padding: 32px !important; }
.gap-1 { gap: 4px; } .gap-2 { gap: 8px; } .gap-3 { gap: 16px; } .gap-4 { gap: 24px; }
.flex-wrap { flex-wrap: wrap; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.justify-end { justify-content: flex-end; }
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-4 { grid-template-columns: repeat(4, 1fr); }
.w-full { width: 100%; }
/* ── Animations ── */
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideIn { from { transform: translateY(-10px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
/* ── Print ── */
@media print {
.sidebar, .topbar, .footer, .page-actions, .btn, .alerts-wrapper, .pagination-wrapper, .toast-container { display: none !important; }
.main-wrapper { margin-right: 0 !important; }
.content-area { padding: 0 !important; }
body { background: #fff; }
}
.d-print-none { }
.d-print-block { display: none; }
@media print { .d-print-none { display: none !important; } .d-print-block { display: block !important; } }
/* ── Responsive ── */
@media (max-width: 1279px) {
.sidebar { transform: translateX(100%); }
.sidebar.open { transform: translateX(0); }
.main-wrapper { margin-right: 0; }
.sidebar-toggle, .sidebar-mobile-toggle { display: block; }
}
@media (max-width: 767px) {
.topbar-search-input { width: 150px; }
.form-row { grid-template-columns: 1fr; }
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
.footer { flex-direction: column; gap: 4px; text-align: center; }
}
\ No newline at end of file
/* ════════════════════════════════════════════════════════════
THE CLUB ERP — Core JavaScript (Vanilla ES6+)
════════════════════════════════════════════════════════════ */
// ── CSRF Token ──
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
// ── AJAX Helper ──
async function ajax(method, url, data = null, options = {}) {
const headers = {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': getCsrfToken(),
};
const config = { method: method.toUpperCase(), headers };
if (data) {
if (data instanceof FormData) {
config.body = data;
} else {
headers['Content-Type'] = 'application/json';
config.body = JSON.stringify(data);
}
}
config.headers = headers;
try {
const response = await fetch(url, config);
const contentType = response.headers.get('Content-Type') || '';
let result;
if (contentType.includes('json')) {
result = await response.json();
} else {
result = await response.text();
}
if (!response.ok) {
if (response.status === 419) {
toast('انتهت صلاحية الجلسة. يرجى إعادة تحميل الصفحة.', 'error');
return null;
}
if (response.status === 422 && result.errors) {
showFormErrors(result.errors);
}
if (!options.silent) {
toast(result.message || result.error || 'حدث خطأ', 'error');
}
return null;
}
return result;
} catch (err) {
if (!options.silent) {
toast('خطأ في الاتصال بالخادم', 'error');
}
console.error('AJAX Error:', err);
return null;
}
}
// ── Toast Notifications ──
function toast(message, type = 'info', duration = 5000) {
const container = document.getElementById('toast-container');
if (!container) return;
const el = document.createElement('div');
el.className = `toast toast-${type}`;
el.innerHTML = `<span>${escapeHtml(message)}</span><button class="toast-close" onclick="this.parentElement.remove()">✕</button>`;
container.appendChild(el);
if (duration > 0) {
setTimeout(() => { if (el.parentElement) el.remove(); }, duration);
}
}
// ── Modal System ──
function openModal(id) {
const modal = document.getElementById(id);
if (modal) modal.style.display = 'flex';
}
function closeModal(id) {
const modal = document.getElementById(id);
if (modal) modal.style.display = 'none';
}
function confirmModal(title, message, onConfirm) {
const id = 'confirm-modal-' + Date.now();
const html = `
<div class="modal-overlay" id="${id}" style="display:flex;">
<div class="modal modal-small">
<div class="modal-header">
<h3 class="modal-title">${escapeHtml(title)}</h3>
<button class="modal-close" onclick="closeModal('${id}');document.getElementById('${id}').remove();">✕</button>
</div>
<div class="modal-body"><p>${escapeHtml(message)}</p></div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('${id}');document.getElementById('${id}').remove();">إلغاء</button>
<button class="btn btn-danger" id="${id}-confirm">تأكيد</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', html);
document.getElementById(`${id}-confirm`).addEventListener('click', function() {
closeModal(id);
document.getElementById(id).remove();
onConfirm();
});
}
// ── Sidebar Toggle ──
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
}
function toggleSubmenu(el) {
const parent = el.closest('.sidebar-item');
const submenu = parent.querySelector('.sidebar-submenu');
if (submenu) {
const isOpen = submenu.style.display === 'block';
submenu.style.display = isOpen ? 'none' : 'block';
parent.classList.toggle('open');
}
}
// ── National ID Parser ──
function initNationalIdParser(inputId, config = {}) {
const input = document.getElementById(inputId);
if (!input) return;
input.addEventListener('input', function() {
this.value = this.value.replace(/\D/g, '').substring(0, 14);
if (this.value.length === 14) {
parseNid(this.value, config);
}
});
}
async function parseNid(nid, config = {}) {
const result = await ajax('POST', '/api/members/parse-nid', { national_id: nid }, { silent: true });
if (result && result.success) {
const data = result.data;
const map = config.fieldMap || {
date_of_birth: 'field_date_of_birth',
age_years: 'field_age_years',
gender: 'field_gender',
governorate_code: 'field_governorate_code',
};
for (const [key, fieldId] of Object.entries(map)) {
const el = document.getElementById(fieldId);
if (el && data[key] !== undefined) {
el.value = data[key];
el.setAttribute('readonly', 'readonly');
el.style.backgroundColor = '#f3f4f6';
}
}
}
}
// ── Form Utilities ──
function showFormErrors(errors) {
document.querySelectorAll('.form-error').forEach(el => el.remove());
document.querySelectorAll('.has-error').forEach(el => el.classList.remove('has-error'));
for (const [field, messages] of Object.entries(errors)) {
const input = document.querySelector(`[name="${field}"]`);
if (input) {
const group = input.closest('.form-group');
if (group) group.classList.add('has-error');
const errorDiv = document.createElement('div');
errorDiv.className = 'form-error';
errorDiv.textContent = Array.isArray(messages) ? messages[0] : messages;
input.parentNode.insertBefore(errorDiv, input.nextSibling);
}
}
}
function initMoneyFormat(inputId) {
const input = document.getElementById(inputId);
if (!input) return;
input.addEventListener('blur', function() {
const num = parseFloat(this.value.replace(/,/g, ''));
if (!isNaN(num)) {
this.value = num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
});
}
function initPhoneFormat(inputId) {
const input = document.getElementById(inputId);
if (!input) return;
input.addEventListener('input', function() {
this.value = this.value.replace(/\D/g, '').substring(0, 11);
});
}
function disableFields(selector) {
document.querySelectorAll(selector).forEach(el => {
el.setAttribute('disabled', 'disabled');
el.style.backgroundColor = '#f3f4f6';
});
}
function enableFields(selector) {
document.querySelectorAll(selector).forEach(el => {
el.removeAttribute('disabled');
el.style.backgroundColor = '';
});
}
function initConditionalVisibility(config) {
for (const rule of config) {
const trigger = document.querySelector(`[name="${rule.field}"]`);
if (!trigger) continue;
const check = () => {
const val = trigger.value;
const target = document.getElementById(rule.targetId) || document.querySelector(rule.targetSelector);
if (!target) return;
let show = false;
if (rule.operator === 'eq') show = val === rule.value;
else if (rule.operator === 'neq') show = val !== rule.value;
else if (rule.operator === 'in') show = rule.value.includes(val);
target.style.display = show ? '' : 'none';
};
trigger.addEventListener('change', check);
trigger.addEventListener('input', check);
check();
}
}
// ── Print Helper ──
function printElement(elementId) {
const el = document.getElementById(elementId);
if (!el) return;
const win = window.open('', '_blank');
win.document.write('<html dir="rtl" lang="ar"><head><meta charset="utf-8">');
win.document.write('<style>body{font-family:Cairo,sans-serif;padding:20px;}table{width:100%;border-collapse:collapse;}th,td{border:1px solid #ddd;padding:8px;text-align:right;}</style>');
win.document.write('</head><body>');
win.document.write(el.innerHTML);
win.document.write('</body></html>');
win.document.close();
win.print();
}
// ── Tabs ──
function initTabs(containerId) {
const container = document.getElementById(containerId);
if (!container) return;
const links = container.querySelectorAll('.tab-link');
const contents = container.querySelectorAll('.tab-content');
links.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const target = this.dataset.tab;
links.forEach(l => l.classList.remove('active'));
contents.forEach(c => c.classList.remove('active'));
this.classList.add('active');
const panel = document.getElementById(target);
if (panel) panel.classList.add('active');
});
});
}
// ── Auto-dismiss alerts ──
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('[data-auto-dismiss]').forEach(el => {
const ms = parseInt(el.dataset.autoDismiss) || 5000;
setTimeout(() => { if (el.parentElement) el.remove(); }, ms);
});
// Close modals on overlay click
document.addEventListener('click', function(e) {
if (e.target.classList.contains('modal-overlay')) {
e.target.style.display = 'none';
}
});
// Escape closes modals
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay').forEach(m => m.style.display = 'none');
}
});
// Ctrl+S submit forms
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
const form = document.querySelector('form');
if (form) form.submit();
}
});
// Init NID parsers
document.querySelectorAll('[data-nid-parser]').forEach(input => {
initNationalIdParser(input.id);
});
});
// ── Helpers ──
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Session timeout warning
(function() {
const timeout = 30 * 60 * 1000; // 30 min
const warningBefore = 5 * 60 * 1000; // 5 min
let timer;
function resetTimer() {
clearTimeout(timer);
timer = setTimeout(function() {
toast('ستنتهي جلستك خلال 5 دقائق. يرجى حفظ عملك.', 'warning', 10000);
}, timeout - warningBefore);
}
['click', 'keydown', 'mousemove', 'scroll'].forEach(evt => {
document.addEventListener(evt, resetTimer, { passive: true });
});
resetTimer();
})();
\ No newline at end of file
<?php
declare(strict_types=1);
// Load environment
$envFile = __DIR__ . '/../.env';
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) continue;
if (str_contains($line, '=')) {
[$key, $val] = explode('=', $line, 2);
$key = trim($key);
$val = trim($val);
$_ENV[$key] = $val;
$_SERVER[$key] = $val;
}
}
}
require_once __DIR__ . '/../app/Core/Autoloader.php';
\App\Core\Autoloader::register();
\App\Core\ExceptionHandler::register();
$app = \App\Core\App::getInstance();
$app->boot();
$request = \App\Core\Request::capture();
$response = $app->router()->dispatch($request);
$response->send();
\ 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