Commit 1e79566a authored by Administrator's avatar Administrator

Update 11 files via Son of Anton

parent 2ed42d50
<?php
declare(strict_types=1);
namespace App\Modules\Archive\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\Archive\Models\ArchiveSnapshot;
use App\Modules\Archive\Services\ArchiveService;
class ArchiveController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'entity_type' => $request->get('entity_type', ''),
'membership_number' => $request->get('membership_number', ''),
'snapshot_reason' => $request->get('snapshot_reason', ''),
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
'search' => trim((string) $request->get('q', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = ArchiveSnapshot::search($filters, 25, $page);
$entityTypes = ArchiveSnapshot::getDistinctEntityTypes();
$reasons = ArchiveSnapshot::getDistinctReasons();
return $this->view('Archive.Views.index', [
'rows' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'entityTypes' => $entityTypes,
'reasons' => $reasons,
]);
}
public function show(Request $request, string $id): Response
{
$snapshot = ArchiveService::getSnapshot((int) $id);
if (!$snapshot) {
return $this->redirect('/archive')->withError('اللقطة الأرشيفية غير موجودة');
}
// Get other snapshots for same entity for navigation
$otherSnapshots = ArchiveSnapshot::getForEntity($snapshot['entity_type'], (int) $snapshot['entity_id']);
return $this->view('Archive.Views.show', [
'snapshot' => $snapshot,
'otherSnapshots' => $otherSnapshots,
]);
}
public function compare(Request $request, string $id1, string $id2): Response
{
try {
$diff = ArchiveService::compareSnapshots((int) $id1, (int) $id2);
} catch (\RuntimeException $e) {
return $this->redirect('/archive')->withError($e->getMessage());
}
$snap1 = ArchiveService::getSnapshot((int) $id1);
$snap2 = ArchiveService::getSnapshot((int) $id2);
return $this->view('Archive.Views.compare', [
'diff' => $diff,
'snap1' => $snap1,
'snap2' => $snap2,
]);
}
public function entitySnapshots(Request $request, string $type, string $id): Response
{
$snapshots = ArchiveService::getSnapshots($type, (int) $id);
return $this->view('Archive.Views.index', [
'rows' => $snapshots,
'pagination' => ['last_page' => 1, 'current_page' => 1],
'filters' => ['entity_type' => $type, 'search' => '', 'membership_number' => '', 'snapshot_reason' => '', 'date_from' => '', 'date_to' => ''],
'entityTypes' => ArchiveSnapshot::getDistinctEntityTypes(),
'reasons' => ArchiveSnapshot::getDistinctReasons(),
'entityFilter' => ['type' => $type, 'id' => (int) $id],
]);
}
public function numberChain(Request $request, string $number): Response
{
$chain = ArchiveService::getNumberChain($number);
$snapshots = ArchiveSnapshot::getByMembershipNumber($number);
return $this->view('Archive.Views.show', [
'snapshot' => null,
'otherSnapshots' => $snapshots,
'numberChain' => $chain,
'membershipNumber' => $number,
'isChainView' => true,
]);
}
public function takeManual(Request $request): Response
{
$entityType = trim((string) $request->post('entity_type', ''));
$entityId = (int) $request->post('entity_id', 0);
$reason = trim((string) $request->post('reason', 'manual'));
$notes = trim((string) $request->post('notes', ''));
if ($entityType === '' || $entityId <= 0) {
return $this->redirect('/archive')->withError('بيانات غير مكتملة');
}
try {
$snapshotId = ArchiveService::takeSnapshot(
$entityType,
$entityId,
$reason,
$notes ?: null
);
return $this->redirect("/archive/{$snapshotId}")->withSuccess('تم أخذ اللقطة الأرشيفية بنجاح');
} catch (\RuntimeException $e) {
return $this->redirect('/archive')->withError('فشل أخذ اللقطة: ' . $e->getMessage());
}
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Archive\Models;
use App\Core\App;
use App\Core\Pagination;
class ArchiveSnapshot
{
public static function create(array $data): int
{
$db = App::getInstance()->db();
return $db->insert('archive_snapshots', $data);
}
public static function find(int $id): ?array
{
$db = App::getInstance()->db();
return $db->selectOne("SELECT * FROM archive_snapshots WHERE id = ?", [$id]);
}
public static function getForEntity(string $entityType, int $entityId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM archive_snapshots WHERE entity_type = ? AND entity_id = ? ORDER BY snapshot_taken_at DESC",
[$entityType, $entityId]
);
}
public static function getByMembershipNumber(string $number): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM archive_snapshots WHERE membership_number = ? ORDER BY snapshot_taken_at DESC",
[$number]
);
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['entity_type'])) {
$where .= ' AND a.entity_type = ?';
$params[] = $filters['entity_type'];
}
if (!empty($filters['membership_number'])) {
$where .= ' AND a.membership_number = ?';
$params[] = $filters['membership_number'];
}
if (!empty($filters['snapshot_reason'])) {
$where .= ' AND a.snapshot_reason = ?';
$params[] = $filters['snapshot_reason'];
}
if (!empty($filters['date_from'])) {
$where .= ' AND a.snapshot_taken_at >= ?';
$params[] = $filters['date_from'] . ' 00:00:00';
}
if (!empty($filters['date_to'])) {
$where .= ' AND a.snapshot_taken_at <= ?';
$params[] = $filters['date_to'] . ' 23:59:59';
}
if (!empty($filters['search'])) {
$where .= ' AND (a.membership_number LIKE ? OR a.entity_type LIKE ? OR a.notes LIKE ?)';
$s = '%' . $filters['search'] . '%';
$params[] = $s;
$params[] = $s;
$params[] = $s;
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM archive_snapshots a WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT a.* FROM archive_snapshots a WHERE {$where} ORDER BY a.snapshot_taken_at DESC LIMIT {$perPage} OFFSET {$offset}",
$params
);
$pagination = Pagination::paginate($total, $perPage, $page);
return ['data' => $rows, 'pagination' => $pagination];
}
public static function getDistinctEntityTypes(): array
{
$db = App::getInstance()->db();
$rows = $db->select("SELECT DISTINCT entity_type FROM archive_snapshots ORDER BY entity_type");
return array_column($rows, 'entity_type');
}
public static function getDistinctReasons(): array
{
$db = App::getInstance()->db();
$rows = $db->select("SELECT DISTINCT snapshot_reason FROM archive_snapshots ORDER BY snapshot_reason");
return array_column($rows, 'snapshot_reason');
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Archive\Models;
use App\Core\App;
class MembershipNumberChain
{
public static function create(array $data): int
{
$db = App::getInstance()->db();
return $db->insert('membership_number_chain', $data);
}
public static function find(int $id): ?array
{
$db = App::getInstance()->db();
return $db->selectOne("SELECT * FROM membership_number_chain WHERE id = ?", [$id]);
}
public static function getChainForNumber(string $membershipNumber): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM membership_number_chain WHERE membership_number = ? ORDER BY held_from ASC",
[$membershipNumber]
);
}
public static function getCurrentHolder(string $membershipNumber): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT * FROM membership_number_chain WHERE membership_number = ? AND held_until IS NULL ORDER BY held_from DESC LIMIT 1",
[$membershipNumber]
);
}
public static function getHoldersForEntity(string $entityType, int $entityId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM membership_number_chain WHERE holder_entity_type = ? AND holder_entity_id = ? ORDER BY held_from DESC",
[$entityType, $entityId]
);
}
public static function endCurrentHolder(string $membershipNumber): void
{
$db = App::getInstance()->db();
$db->update(
'membership_number_chain',
['held_until' => date('Y-m-d H:i:s')],
'`membership_number` = ? AND `held_until` IS NULL',
[$membershipNumber]
);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/archive', 'Archive\Controllers\ArchiveController@index', ['auth'], 'report.view_audit'],
['GET', '/archive/{id:\d+}', 'Archive\Controllers\ArchiveController@show', ['auth'], 'report.view_audit'],
['GET', '/archive/compare/{id1:\d+}/{id2:\d+}', 'Archive\Controllers\ArchiveController@compare', ['auth'], 'report.view_audit'],
['GET', '/archive/entity/{type}/{id:\d+}', 'Archive\Controllers\ArchiveController@entitySnapshots', ['auth'], 'report.view_audit'],
['GET', '/archive/number-chain/{number}', 'Archive\Controllers\ArchiveController@numberChain', ['auth'], 'report.view_audit'],
['POST', '/archive/snapshot', 'Archive\Controllers\ArchiveController@takeManual', ['auth', 'csrf'], 'settings.edit'],
];
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الأرشيف<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/archive" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="q" value="<?= e($filters['search'] ?? '') ?>" placeholder="بحث..." class="form-input" style="min-width:150px;">
</div>
<div>
<label class="form-label" style="font-size:12px;">نوع الكيان</label>
<select name="entity_type" class="form-select" style="min-width:120px;">
<option value="">الكل</option>
<?php foreach ($entityTypes as $et): ?>
<option value="<?= e($et) ?>" <?= ($filters['entity_type'] ?? '') === $et ? 'selected' : '' ?>><?= e($et) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">السبب</label>
<select name="snapshot_reason" class="form-select" style="min-width:120px;">
<option value="">الكل</option>
<?php foreach ($reasons as $r): ?>
<option value="<?= e($r) ?>" <?= ($filters['snapshot_reason'] ?? '') === $r ? 'selected' : '' ?>><?= e($r) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">رقم العضوية</label>
<input type="text" name="membership_number" value="<?= e($filters['membership_number'] ?? '') ?>" placeholder="رقم العضوية" class="form-input" style="min-width:120px;">
</div>
<div>
<label class="form-label" style="font-size:12px;">من</label>
<input type="date" name="date_from" value="<?= e($filters['date_from'] ?? '') ?>" class="form-input">
</div>
<div>
<label class="form-label" style="font-size:12px;">إلى</label>
<input type="date" name="date_to" value="<?= e($filters['date_to'] ?? '') ?>" class="form-input">
</div>
<button type="submit" class="btn btn-outline">بحث</button>
<a href="/archive" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>التاريخ</th>
<th>نوع الكيان</th>
<th>رقم الكيان</th>
<th>رقم العضوية</th>
<th>السبب</th>
<th>بواسطة</th>
<th>ملاحظات</th>
<th>الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td><?= (int) $r['id'] ?></td>
<td style="font-size:12px;white-space:nowrap;"><?= e($r['snapshot_taken_at']) ?></td>
<td><code style="font-size:12px;"><?= e($r['entity_type']) ?></code></td>
<td><?= (int) $r['entity_id'] ?></td>
<td>
<?php if ($r['membership_number']): ?>
<a href="/archive/number-chain/<?= urlencode($r['membership_number']) ?>" style="color:#0D7377;font-weight:600;"><?= e($r['membership_number']) ?></a>
<?php else: ?>
<?php endif; ?>
</td>
<td>
<?php
$reasonColors = [
'transfer' => '#0284C7', 'separation' => '#D97706', 'divorce' => '#DC2626',
'death' => '#6B7280', 'waiver' => '#7C3AED', 'status_change' => '#059669',
'data_correction' => '#0D7377', 'manual' => '#9CA3AF',
];
$color = $reasonColors[$r['snapshot_reason']] ?? '#6B7280';
?>
<span style="color:<?= $color ?>;font-weight:600;font-size:13px;"><?= e($r['snapshot_reason']) ?></span>
</td>
<td style="font-size:13px;"><?= $r['snapshot_taken_by'] ? '#' . (int) $r['snapshot_taken_by'] : 'النظام' ?></td>
<td style="font-size:12px;color:#6B7280;max-width:200px;overflow:hidden;text-overflow:ellipsis;"><?= e(mb_substr($r['notes'] ?? '', 0, 80)) ?: '—' ?></td>
<td>
<div style="display:flex;gap:5px;">
<a href="/archive/<?= (int) $r['id'] ?>" class="btn btn-sm btn-outline">عرض</a>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?>
<tr><td colspan="9" style="text-align:center;padding:40px;color:#6B7280;">لا توجد لقطات أرشيفية</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if (isset($pagination['last_page']) && $pagination['last_page'] > 1): ?>
<div style="padding:15px;">
<nav class="pagination-wrapper">
<ul class="pagination" style="display:flex;gap:5px;list-style:none;padding:0;justify-content:center;">
<?php if ($pagination['has_prev'] ?? false): ?>
<li><a href="?page=<?= $pagination['prev_page'] ?>&q=<?= urlencode($filters['search'] ?? '') ?>&entity_type=<?= urlencode($filters['entity_type'] ?? '') ?>&snapshot_reason=<?= urlencode($filters['snapshot_reason'] ?? '') ?>&membership_number=<?= urlencode($filters['membership_number'] ?? '') ?>&date_from=<?= urlencode($filters['date_from'] ?? '') ?>&date_to=<?= urlencode($filters['date_to'] ?? '') ?>" class="btn btn-sm btn-outline">السابق</a></li>
<?php endif; ?>
<?php foreach ($pagination['pages'] ?? [] as $p): ?>
<?php if ($p === '...'): ?>
<li style="padding:5px;">...</li>
<?php else: ?>
<li><a href="?page=<?= $p ?>&q=<?= urlencode($filters['search'] ?? '') ?>&entity_type=<?= urlencode($filters['entity_type'] ?? '') ?>&snapshot_reason=<?= urlencode($filters['snapshot_reason'] ?? '') ?>&membership_number=<?= urlencode($filters['membership_number'] ?? '') ?>&date_from=<?= urlencode($filters['date_from'] ?? '') ?>&date_to=<?= urlencode($filters['date_to'] ?? '') ?>" class="btn btn-sm <?= $p === ($pagination['current_page'] ?? 1) ? 'btn-primary' : 'btn-outline' ?>"><?= $p ?></a></li>
<?php endif; ?>
<?php endforeach; ?>
<?php if ($pagination['has_next'] ?? false): ?>
<li><a href="?page=<?= $pagination['next_page'] ?>&q=<?= urlencode($filters['search'] ?? '') ?>&entity_type=<?= urlencode($filters['entity_type'] ?? '') ?>&snapshot_reason=<?= urlencode($filters['snapshot_reason'] ?? '') ?>&membership_number=<?= urlencode($filters['membership_number'] ?? '') ?>&date_from=<?= urlencode($filters['date_from'] ?? '') ?>&date_to=<?= urlencode($filters['date_to'] ?? '') ?>" class="btn btn-sm btn-outline">التالي</a></li>
<?php endif; ?>
</ul>
</nav>
</div>
<?php endif; ?>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
This diff is collapsed.
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
// Add archive as a child under the existing branches_settings menu
// We check if the menu already exists and add our child to it
$existing = MenuRegistry::get('branches_settings');
if ($existing) {
$children = $existing['children'] ?? [];
$children[] = ['label_ar' => 'الأرشيف', 'label_en' => 'Archive', 'route' => '/archive', 'permission' => 'report.view_audit', 'order' => 4];
$existing['children'] = $children;
MenuRegistry::register('branches_settings', $existing);
}
PermissionRegistry::register('archive', [
'archive.view' => ['ar' => 'عرض الأرشيف', 'en' => 'View Archive'],
'archive.take_snapshot' => ['ar' => 'أخذ لقطة أرشيفية', 'en' => 'Take Archive Snapshot'],
'archive.compare' => ['ar' => 'مقارنة اللقطات', 'en' => 'Compare Snapshots'],
'archive.number_chain' => ['ar' => 'سلسلة أرقام العضوية', 'en' => 'Number Chain'],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `archive_snapshots` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`entity_type` VARCHAR(100) NOT NULL,
`entity_id` BIGINT UNSIGNED NOT NULL,
`membership_number` VARCHAR(20) NULL,
`snapshot_reason` VARCHAR(50) NOT NULL,
`full_data_json` JSON NOT NULL,
`related_data_json` JSON NULL,
`snapshot_taken_by` BIGINT UNSIGNED NULL,
`snapshot_taken_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`notes` TEXT NULL,
INDEX `idx_archive_entity` (`entity_type`, `entity_id`),
INDEX `idx_archive_membership` (`membership_number`),
INDEX `idx_archive_date` (`snapshot_taken_at`),
INDEX `idx_archive_reason` (`snapshot_reason`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `archive_snapshots`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `membership_number_chain` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`membership_number` VARCHAR(20) NOT NULL,
`holder_type` VARCHAR(50) NOT NULL,
`holder_entity_type` VARCHAR(100) NOT NULL,
`holder_entity_id` BIGINT UNSIGNED NOT NULL,
`previous_holder_id` BIGINT UNSIGNED NULL,
`held_from` DATETIME NOT NULL,
`held_until` DATETIME NULL DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_number_chain_number` (`membership_number`),
INDEX `idx_number_chain_holder` (`holder_entity_type`, `holder_entity_id`),
CONSTRAINT `fk_number_chain_prev` FOREIGN KEY (`previous_holder_id`) REFERENCES `membership_number_chain`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `membership_number_chain`",
];
\ 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