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
This diff is collapsed.
<?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
This diff is collapsed.
This diff is collapsed.
<?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