Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
C
Clubphp
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
Clubphp
Commits
1e79566a
Commit
1e79566a
authored
Apr 07, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 11 files via Son of Anton
parent
2ed42d50
Changes
11
Hide whitespace changes
Inline
Side-by-side
Showing
11 changed files
with
1289 additions
and
0 deletions
+1289
-0
ArchiveController.php
app/Modules/Archive/Controllers/ArchiveController.php
+125
-0
ArchiveSnapshot.php
app/Modules/Archive/Models/ArchiveSnapshot.php
+107
-0
MembershipNumberChain.php
app/Modules/Archive/Models/MembershipNumberChain.php
+59
-0
Routes.php
app/Modules/Archive/Routes.php
+11
-0
ArchiveService.php
app/Modules/Archive/Services/ArchiveService.php
+397
-0
compare.php
app/Modules/Archive/Views/compare.php
+166
-0
index.php
app/Modules/Archive/Views/index.php
+123
-0
show.php
app/Modules/Archive/Views/show.php
+233
-0
bootstrap.php
app/Modules/Archive/bootstrap.php
+22
-0
Phase_04_001_create_archive_snapshots_table.php
...igrations/Phase_04_001_create_archive_snapshots_table.php
+24
-0
Phase_04_002_create_membership_number_chain_table.php
...ons/Phase_04_002_create_membership_number_chain_table.php
+22
-0
No files found.
app/Modules/Archive/Controllers/ArchiveController.php
0 → 100644
View file @
1e79566a
<?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
app/Modules/Archive/Models/ArchiveSnapshot.php
0 → 100644
View file @
1e79566a
<?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
app/Modules/Archive/Models/MembershipNumberChain.php
0 → 100644
View file @
1e79566a
<?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
app/Modules/Archive/Routes.php
0 → 100644
View file @
1e79566a
<?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
app/Modules/Archive/Services/ArchiveService.php
0 → 100644
View file @
1e79566a
<?php
declare
(
strict_types
=
1
);
namespace
App\Modules\Archive\Services
;
use
App\Core\App
;
use
App\Core\Logger
;
use
App\Modules\Archive\Models\ArchiveSnapshot
;
use
App\Modules\Archive\Models\MembershipNumberChain
;
final
class
ArchiveService
{
/**
* Takes a complete snapshot of an entity and all its related data.
* This is the core archive operation — called before major lifecycle events.
*
* @param string $entityType Table name or entity identifier (e.g., 'members', 'employees')
* @param int $entityId Primary key of the entity
* @param string $reason Why the snapshot is being taken
* @param string|null $notes Optional human-readable notes
* @param array|null $relatedData Optional pre-loaded related data
* @param string|null $membershipNumber Optional membership number for cross-reference
* @return int The snapshot ID
*/
public
static
function
takeSnapshot
(
string
$entityType
,
int
$entityId
,
string
$reason
,
?
string
$notes
=
null
,
?
array
$relatedData
=
null
,
?
string
$membershipNumber
=
null
)
:
int
{
$db
=
App
::
getInstance
()
->
db
();
$employee
=
App
::
getInstance
()
->
currentEmployee
();
// Load the main entity record
$entityData
=
$db
->
selectOne
(
"SELECT * FROM `
{
$entityType
}
` WHERE id = ?"
,
[
$entityId
]);
if
(
!
$entityData
)
{
throw
new
\RuntimeException
(
"Entity not found:
{
$entityType
}
#
{
$entityId
}
"
);
}
// If membership_number not provided, try to extract from entity data
if
(
$membershipNumber
===
null
&&
isset
(
$entityData
[
'membership_number'
]))
{
$membershipNumber
=
$entityData
[
'membership_number'
];
}
// If related data not provided, try to auto-load based on entity type
if
(
$relatedData
===
null
)
{
$relatedData
=
self
::
autoLoadRelatedData
(
$entityType
,
$entityId
,
$db
);
}
$snapshotId
=
ArchiveSnapshot
::
create
([
'entity_type'
=>
$entityType
,
'entity_id'
=>
$entityId
,
'membership_number'
=>
$membershipNumber
,
'snapshot_reason'
=>
$reason
,
'full_data_json'
=>
json_encode
(
$entityData
,
JSON_UNESCAPED_UNICODE
),
'related_data_json'
=>
$relatedData
!==
null
?
json_encode
(
$relatedData
,
JSON_UNESCAPED_UNICODE
)
:
null
,
'snapshot_taken_by'
=>
$employee
?
(
int
)
(
$employee
->
id
??
0
)
:
null
,
'snapshot_taken_at'
=>
date
(
'Y-m-d H:i:s'
),
'notes'
=>
$notes
,
]);
Logger
::
info
(
"Archive snapshot taken"
,
[
'snapshot_id'
=>
$snapshotId
,
'entity_type'
=>
$entityType
,
'entity_id'
=>
$entityId
,
'reason'
=>
$reason
,
]);
return
$snapshotId
;
}
/**
* Auto-loads related data for known entity types.
* For unknown types, returns null (caller can provide their own).
*/
private
static
function
autoLoadRelatedData
(
string
$entityType
,
int
$entityId
,
$db
)
:
?
array
{
$related
=
[];
switch
(
$entityType
)
{
case
'members'
:
// Load spouses if table exists
if
(
$db
->
tableExists
(
'spouses'
))
{
$related
[
'spouses'
]
=
$db
->
select
(
"SELECT * FROM spouses WHERE member_id = ? AND is_archived = 0"
,
[
$entityId
]
);
}
// Load children if table exists
if
(
$db
->
tableExists
(
'children'
))
{
$related
[
'children'
]
=
$db
->
select
(
"SELECT * FROM children WHERE member_id = ? AND is_archived = 0"
,
[
$entityId
]
);
}
// Load temporary members if table exists
if
(
$db
->
tableExists
(
'temporary_members'
))
{
$related
[
'temporary_members'
]
=
$db
->
select
(
"SELECT * FROM temporary_members WHERE member_id = ? AND is_archived = 0"
,
[
$entityId
]
);
}
// Load payments if table exists
if
(
$db
->
tableExists
(
'payments'
))
{
$related
[
'payments'
]
=
$db
->
select
(
"SELECT * FROM payments WHERE member_id = ? AND is_voided = 0 ORDER BY payment_date DESC"
,
[
$entityId
]
);
}
// Load installment plans if table exists
if
(
$db
->
tableExists
(
'installment_plans'
))
{
$related
[
'installment_plans'
]
=
$db
->
select
(
"SELECT * FROM installment_plans WHERE member_id = ?"
,
[
$entityId
]
);
}
// Load subscriptions if table exists
if
(
$db
->
tableExists
(
'subscriptions'
))
{
$related
[
'subscriptions'
]
=
$db
->
select
(
"SELECT * FROM subscriptions WHERE member_id = ? ORDER BY financial_year DESC"
,
[
$entityId
]
);
}
// Load fines if table exists
if
(
$db
->
tableExists
(
'fines'
))
{
$related
[
'fines'
]
=
$db
->
select
(
"SELECT * FROM fines WHERE member_id = ?"
,
[
$entityId
]
);
}
// Load documents if table exists
if
(
$db
->
tableExists
(
'documents'
))
{
$related
[
'documents'
]
=
$db
->
select
(
"SELECT id, member_id, document_type, original_filename, file_path, uploaded_at FROM documents WHERE member_id = ? AND is_archived = 0"
,
[
$entityId
]
);
}
// Load seasonal if table exists
if
(
$db
->
tableExists
(
'seasonal_memberships'
))
{
$related
[
'seasonal_memberships'
]
=
$db
->
select
(
"SELECT * FROM seasonal_memberships WHERE member_id = ? AND is_archived = 0"
,
[
$entityId
]
);
}
// Load sports if table exists
if
(
$db
->
tableExists
(
'sports_members'
))
{
$related
[
'sports_members'
]
=
$db
->
select
(
"SELECT * FROM sports_members WHERE member_id = ? AND is_archived = 0"
,
[
$entityId
]
);
}
// Load honorary if table exists
if
(
$db
->
tableExists
(
'honorary_members'
))
{
$related
[
'honorary_members'
]
=
$db
->
select
(
"SELECT * FROM honorary_members WHERE member_id = ? AND is_archived = 0"
,
[
$entityId
]
);
}
// Load foreign details if table exists
if
(
$db
->
tableExists
(
'foreign_member_details'
))
{
$related
[
'foreign_member_details'
]
=
$db
->
select
(
"SELECT * FROM foreign_member_details WHERE member_id = ? AND is_archived = 0"
,
[
$entityId
]
);
}
break
;
case
'employees'
:
// Load employee roles
$related
[
'roles'
]
=
$db
->
select
(
"SELECT er.*, r.role_code, r.name_ar FROM employee_roles er JOIN roles r ON r.id = er.role_id WHERE er.employee_id = ? AND er.is_active = 1"
,
[
$entityId
]
);
break
;
case
'roles'
:
// Load role permissions
$related
[
'permissions'
]
=
$db
->
select
(
"SELECT * FROM role_permissions WHERE role_id = ?"
,
[
$entityId
]
);
break
;
default
:
// For unknown entity types, return empty array — caller can provide their own
return
!
empty
(
$related
)
?
$related
:
null
;
}
return
!
empty
(
$related
)
?
$related
:
null
;
}
/**
* Get all snapshots for an entity.
*/
public
static
function
getSnapshots
(
string
$entityType
,
int
$entityId
)
:
array
{
return
ArchiveSnapshot
::
getForEntity
(
$entityType
,
$entityId
);
}
/**
* Get a single snapshot with decoded JSON.
*/
public
static
function
getSnapshot
(
int
$snapshotId
)
:
?
array
{
$snapshot
=
ArchiveSnapshot
::
find
(
$snapshotId
);
if
(
!
$snapshot
)
{
return
null
;
}
$snapshot
[
'full_data'
]
=
json_decode
(
$snapshot
[
'full_data_json'
]
??
'{}'
,
true
)
??
[];
$snapshot
[
'related_data'
]
=
json_decode
(
$snapshot
[
'related_data_json'
]
??
'null'
,
true
);
return
$snapshot
;
}
/**
* Compare two snapshots and return the differences.
*/
public
static
function
compareSnapshots
(
int
$snapshotId1
,
int
$snapshotId2
)
:
array
{
$snap1
=
self
::
getSnapshot
(
$snapshotId1
);
$snap2
=
self
::
getSnapshot
(
$snapshotId2
);
if
(
!
$snap1
||
!
$snap2
)
{
throw
new
\RuntimeException
(
'One or both snapshots not found'
);
}
$diff
=
[
'snapshot_1'
=>
[
'id'
=>
$snap1
[
'id'
],
'reason'
=>
$snap1
[
'snapshot_reason'
],
'taken_at'
=>
$snap1
[
'snapshot_taken_at'
],
],
'snapshot_2'
=>
[
'id'
=>
$snap2
[
'id'
],
'reason'
=>
$snap2
[
'snapshot_reason'
],
'taken_at'
=>
$snap2
[
'snapshot_taken_at'
],
],
'main_entity_diff'
=>
self
::
diffArrays
(
$snap1
[
'full_data'
],
$snap2
[
'full_data'
]),
'related_diff'
=>
self
::
diffRelatedData
(
$snap1
[
'related_data'
]
??
[],
$snap2
[
'related_data'
]
??
[]
),
];
return
$diff
;
}
/**
* Recursively diff two associative arrays.
* Returns fields that changed with old/new values.
*/
public
static
function
diffArrays
(
array
$old
,
array
$new
)
:
array
{
$diff
=
[];
$allKeys
=
array_unique
(
array_merge
(
array_keys
(
$old
),
array_keys
(
$new
)));
foreach
(
$allKeys
as
$key
)
{
$oldVal
=
$old
[
$key
]
??
null
;
$newVal
=
$new
[
$key
]
??
null
;
if
(
is_array
(
$oldVal
)
&&
is_array
(
$newVal
))
{
$subDiff
=
self
::
diffArrays
(
$oldVal
,
$newVal
);
if
(
!
empty
(
$subDiff
))
{
$diff
[
$key
]
=
$subDiff
;
}
}
elseif
((
string
)
$oldVal
!==
(
string
)
$newVal
)
{
$diff
[
$key
]
=
[
'old'
=>
$oldVal
,
'new'
=>
$newVal
,
];
}
}
return
$diff
;
}
/**
* Diff related data sections.
*/
private
static
function
diffRelatedData
(
?
array
$old
,
?
array
$new
)
:
array
{
$diff
=
[];
$old
=
$old
??
[];
$new
=
$new
??
[];
$allSections
=
array_unique
(
array_merge
(
array_keys
(
$old
),
array_keys
(
$new
)));
foreach
(
$allSections
as
$section
)
{
$oldSection
=
$old
[
$section
]
??
[];
$newSection
=
$new
[
$section
]
??
[];
$oldCount
=
count
(
$oldSection
);
$newCount
=
count
(
$newSection
);
if
(
$oldCount
!==
$newCount
)
{
$diff
[
$section
]
=
[
'count_changed'
=>
true
,
'old_count'
=>
$oldCount
,
'new_count'
=>
$newCount
,
];
}
// Compare individual records by ID if available
$oldById
=
[];
$newById
=
[];
foreach
(
$oldSection
as
$item
)
{
$id
=
$item
[
'id'
]
??
null
;
if
(
$id
!==
null
)
{
$oldById
[
$id
]
=
$item
;
}
}
foreach
(
$newSection
as
$item
)
{
$id
=
$item
[
'id'
]
??
null
;
if
(
$id
!==
null
)
{
$newById
[
$id
]
=
$item
;
}
}
// Added records
$addedIds
=
array_diff
(
array_keys
(
$newById
),
array_keys
(
$oldById
));
if
(
!
empty
(
$addedIds
))
{
$diff
[
$section
][
'added'
]
=
array_values
(
array_intersect_key
(
$newById
,
array_flip
(
$addedIds
)));
}
// Removed records
$removedIds
=
array_diff
(
array_keys
(
$oldById
),
array_keys
(
$newById
));
if
(
!
empty
(
$removedIds
))
{
$diff
[
$section
][
'removed'
]
=
array_values
(
array_intersect_key
(
$oldById
,
array_flip
(
$removedIds
)));
}
// Changed records
$commonIds
=
array_intersect
(
array_keys
(
$oldById
),
array_keys
(
$newById
));
foreach
(
$commonIds
as
$id
)
{
$fieldDiff
=
self
::
diffArrays
(
$oldById
[
$id
],
$newById
[
$id
]);
if
(
!
empty
(
$fieldDiff
))
{
$diff
[
$section
][
'changed'
][
$id
]
=
$fieldDiff
;
}
}
}
return
$diff
;
}
/**
* Record a membership number transfer in the chain.
*/
public
static
function
recordNumberTransfer
(
string
$membershipNumber
,
string
$holderType
,
string
$entityType
,
int
$entityId
,
?
int
$previousHolderId
=
null
)
:
int
{
// End the current holder's record
MembershipNumberChain
::
endCurrentHolder
(
$membershipNumber
);
// Create new chain entry
$chainId
=
MembershipNumberChain
::
create
([
'membership_number'
=>
$membershipNumber
,
'holder_type'
=>
$holderType
,
'holder_entity_type'
=>
$entityType
,
'holder_entity_id'
=>
$entityId
,
'previous_holder_id'
=>
$previousHolderId
,
'held_from'
=>
date
(
'Y-m-d H:i:s'
),
'held_until'
=>
null
,
'created_at'
=>
date
(
'Y-m-d H:i:s'
),
]);
Logger
::
info
(
"Membership number chain updated"
,
[
'membership_number'
=>
$membershipNumber
,
'holder_type'
=>
$holderType
,
'entity_type'
=>
$entityType
,
'entity_id'
=>
$entityId
,
'chain_id'
=>
$chainId
,
]);
return
$chainId
;
}
/**
* Get the complete ownership chain for a membership number.
*/
public
static
function
getNumberChain
(
string
$membershipNumber
)
:
array
{
return
MembershipNumberChain
::
getChainForNumber
(
$membershipNumber
);
}
/**
* Get the current holder of a membership number.
*/
public
static
function
getCurrentHolder
(
string
$membershipNumber
)
:
?
array
{
return
MembershipNumberChain
::
getCurrentHolder
(
$membershipNumber
);
}
}
\ No newline at end of file
app/Modules/Archive/Views/compare.php
0 → 100644
View file @
1e79566a
<?php
$__template
->
layout
(
'Layout.main'
);
?>
<?php
$__template
->
section
(
'title'
);
?>
مقارنة اللقطات: #
<?=
(
int
)
(
$snap1
[
'id'
]
??
0
)
?>
↔ #
<?=
(
int
)
(
$snap2
[
'id'
]
??
0
)
?><?php
$__template
->
endSection
();
?>
<?php
$__template
->
section
(
'page_actions'
);
?>
<a
href=
"/archive/
<?=
(
int
)
(
$snap1
[
'id'
]
??
0
)
?>
"
class=
"btn btn-outline"
>
← لقطة #
<?=
(
int
)
(
$snap1
[
'id'
]
??
0
)
?>
</a>
<a
href=
"/archive/
<?=
(
int
)
(
$snap2
[
'id'
]
??
0
)
?>
"
class=
"btn btn-outline"
>
← لقطة #
<?=
(
int
)
(
$snap2
[
'id'
]
??
0
)
?>
</a>
<?php
$__template
->
endSection
();
?>
<?php
$__template
->
section
(
'content'
);
?>
<!-- Comparison Header -->
<div
style=
"display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px;"
>
<div
class=
"card"
style=
"padding:15px;border-right:4px solid #DC2626;"
>
<div
style=
"font-weight:700;color:#DC2626;margin-bottom:5px;"
>
لقطة #
<?=
(
int
)
(
$diff
[
'snapshot_1'
][
'id'
]
??
0
)
?>
(قبل)
</div>
<div
style=
"font-size:13px;color:#6B7280;"
>
السبب:
<?=
e
(
$diff
[
'snapshot_1'
][
'reason'
]
??
''
)
?>
</div>
<div
style=
"font-size:12px;color:#9CA3AF;"
>
<?=
e
(
$diff
[
'snapshot_1'
][
'taken_at'
]
??
''
)
?>
</div>
</div>
<div
class=
"card"
style=
"padding:15px;border-right:4px solid #059669;"
>
<div
style=
"font-weight:700;color:#059669;margin-bottom:5px;"
>
لقطة #
<?=
(
int
)
(
$diff
[
'snapshot_2'
][
'id'
]
??
0
)
?>
(بعد)
</div>
<div
style=
"font-size:13px;color:#6B7280;"
>
السبب:
<?=
e
(
$diff
[
'snapshot_2'
][
'reason'
]
??
''
)
?>
</div>
<div
style=
"font-size:12px;color:#9CA3AF;"
>
<?=
e
(
$diff
[
'snapshot_2'
][
'taken_at'
]
??
''
)
?>
</div>
</div>
</div>
<!-- Main Entity Diff -->
<?php
$mainDiff
=
$diff
[
'main_entity_diff'
]
??
[];
?>
<?php
if
(
empty
(
$mainDiff
))
:
?>
<div
class=
"card"
style=
"padding:30px;text-align:center;color:#059669;margin-bottom:20px;"
>
<strong>
✓ لا توجد اختلافات في بيانات الكيان الرئيسية
</strong>
</div>
<?php
else
:
?>
<div
class=
"card"
style=
"margin-bottom:20px;"
>
<div
style=
"padding:15px 20px;border-bottom:1px solid #E5E7EB;"
>
<h3
style=
"margin:0;color:#0D7377;"
>
اختلافات الكيان الرئيسي
<span
style=
"color:#DC2626;font-size:14px;"
>
(
<?=
count
(
$mainDiff
)
?>
حقل)
</span></h3>
</div>
<div
class=
"table-responsive"
>
<table
class=
"data-table"
>
<thead>
<tr>
<th
style=
"width:30%;"
>
الحقل
</th>
<th
style=
"width:35%;"
>
القيمة القديمة
</th>
<th
style=
"width:35%;"
>
القيمة الجديدة
</th>
</tr>
</thead>
<tbody>
<?php
foreach
(
$mainDiff
as
$field
=>
$change
)
:
?>
<tr>
<td
style=
"font-weight:600;"
>
<?=
e
(
$field
)
?>
</td>
<?php
if
(
isset
(
$change
[
'old'
])
||
isset
(
$change
[
'new'
]))
:
?>
<td
style=
"background:#FEF2F2;color:#DC2626;font-size:13px;word-break:break-all;"
>
<?php
$oldVal
=
$change
[
'old'
]
??
null
;
if
(
$oldVal
===
null
)
{
echo
'<span style="color:#D1D5DB;">NULL</span>'
;
}
elseif
(
is_array
(
$oldVal
))
{
echo
'<code style="font-size:11px;">'
.
e
(
json_encode
(
$oldVal
,
JSON_UNESCAPED_UNICODE
))
.
'</code>'
;
}
else
{
echo
e
((
string
)
$oldVal
);
}
?>
</td>
<td
style=
"background:#F0FDF4;color:#059669;font-size:13px;word-break:break-all;"
>
<?php
$newVal
=
$change
[
'new'
]
??
null
;
if
(
$newVal
===
null
)
{
echo
'<span style="color:#D1D5DB;">NULL</span>'
;
}
elseif
(
is_array
(
$newVal
))
{
echo
'<code style="font-size:11px;">'
.
e
(
json_encode
(
$newVal
,
JSON_UNESCAPED_UNICODE
))
.
'</code>'
;
}
else
{
echo
e
((
string
)
$newVal
);
}
?>
</td>
<?php
else
:
?>
<td
colspan=
"2"
style=
"font-size:12px;color:#6B7280;"
>
<details>
<summary
style=
"cursor:pointer;color:#0D7377;"
>
كائن متداخل — اضغط للعرض
</summary>
<pre
style=
"margin-top:5px;font-size:11px;background:#F9FAFB;padding:8px;border-radius:4px;overflow-x:auto;"
>
<?=
e
(
json_encode
(
$change
,
JSON_UNESCAPED_UNICODE
|
JSON_PRETTY_PRINT
))
?>
</pre>
</details>
</td>
<?php
endif
;
?>
</tr>
<?php
endforeach
;
?>
</tbody>
</table>
</div>
</div>
<?php
endif
;
?>
<!-- Related Data Diff -->
<?php
$relatedDiff
=
$diff
[
'related_diff'
]
??
[];
?>
<?php
if
(
!
empty
(
$relatedDiff
))
:
?>
<?php
foreach
(
$relatedDiff
as
$section
=>
$sectionDiff
)
:
?>
<div
class=
"card"
style=
"margin-bottom:20px;"
>
<div
style=
"padding:15px 20px;border-bottom:1px solid #E5E7EB;"
>
<h3
style=
"margin:0;color:#0D7377;"
>
<?=
e
(
$section
)
?>
</h3>
</div>
<div
style=
"padding:20px;"
>
<?php
if
(
isset
(
$sectionDiff
[
'count_changed'
])
&&
$sectionDiff
[
'count_changed'
])
:
?>
<div
style=
"margin-bottom:15px;padding:10px;background:#FFF7ED;border:1px solid #FED7AA;border-radius:6px;font-size:13px;"
>
عدد السجلات:
<span
style=
"color:#DC2626;"
>
<?=
(
int
)
$sectionDiff
[
'old_count'
]
?>
</span>
→
<span
style=
"color:#059669;"
>
<?=
(
int
)
$sectionDiff
[
'new_count'
]
?>
</span>
</div>
<?php
endif
;
?>
<?php
if
(
!
empty
(
$sectionDiff
[
'added'
]))
:
?>
<div
style=
"margin-bottom:10px;"
>
<strong
style=
"color:#059669;"
>
✚ سجلات مُضافة (
<?=
count
(
$sectionDiff
[
'added'
])
?>
)
</strong>
<?php
foreach
(
$sectionDiff
[
'added'
]
as
$added
)
:
?>
<div
style=
"margin-top:5px;padding:8px;background:#F0FDF4;border:1px solid #BBF7D0;border-radius:4px;font-size:12px;"
>
ID:
<?=
(
int
)
(
$added
[
'id'
]
??
0
)
?>
<?php
if
(
isset
(
$added
[
'full_name_ar'
]))
:
?>
—
<?=
e
(
$added
[
'full_name_ar'
])
?><?php
endif
;
?>
</div>
<?php
endforeach
;
?>
</div>
<?php
endif
;
?>
<?php
if
(
!
empty
(
$sectionDiff
[
'removed'
]))
:
?>
<div
style=
"margin-bottom:10px;"
>
<strong
style=
"color:#DC2626;"
>
✖ سجلات مُزالة (
<?=
count
(
$sectionDiff
[
'removed'
])
?>
)
</strong>
<?php
foreach
(
$sectionDiff
[
'removed'
]
as
$removed
)
:
?>
<div
style=
"margin-top:5px;padding:8px;background:#FEF2F2;border:1px solid #FECACA;border-radius:4px;font-size:12px;"
>
ID:
<?=
(
int
)
(
$removed
[
'id'
]
??
0
)
?>
<?php
if
(
isset
(
$removed
[
'full_name_ar'
]))
:
?>
—
<?=
e
(
$removed
[
'full_name_ar'
])
?><?php
endif
;
?>
</div>
<?php
endforeach
;
?>
</div>
<?php
endif
;
?>
<?php
if
(
!
empty
(
$sectionDiff
[
'changed'
]))
:
?>
<div>
<strong
style=
"color:#D97706;"
>
✎ سجلات مُعدّلة (
<?=
count
(
$sectionDiff
[
'changed'
])
?>
)
</strong>
<?php
foreach
(
$sectionDiff
[
'changed'
]
as
$changedId
=>
$fieldChanges
)
:
?>
<details
style=
"margin-top:5px;border:1px solid #FED7AA;border-radius:4px;"
>
<summary
style=
"padding:8px 12px;cursor:pointer;background:#FFF7ED;font-size:13px;"
>
سجل #
<?=
(
int
)
$changedId
?>
—
<?=
count
(
$fieldChanges
)
?>
حقل متغير
</summary>
<table
style=
"width:100%;font-size:12px;margin:0;"
>
<thead><tr
style=
"background:#F9FAFB;"
><th
style=
"padding:4px 10px;"
>
الحقل
</th><th
style=
"padding:4px 10px;"
>
قبل
</th><th
style=
"padding:4px 10px;"
>
بعد
</th></tr></thead>
<tbody>
<?php
foreach
(
$fieldChanges
as
$fk
=>
$fc
)
:
?>
<?php
if
(
isset
(
$fc
[
'old'
])
||
isset
(
$fc
[
'new'
]))
:
?>
<tr>
<td
style=
"padding:4px 10px;font-weight:600;"
>
<?=
e
(
$fk
)
?>
</td>
<td
style=
"padding:4px 10px;color:#DC2626;background:#FEF2F2;"
>
<?=
e
((
string
)
(
$fc
[
'old'
]
??
'NULL'
))
?>
</td>
<td
style=
"padding:4px 10px;color:#059669;background:#F0FDF4;"
>
<?=
e
((
string
)
(
$fc
[
'new'
]
??
'NULL'
))
?>
</td>
</tr>
<?php
endif
;
?>
<?php
endforeach
;
?>
</tbody>
</table>
</details>
<?php
endforeach
;
?>
</div>
<?php
endif
;
?>
<?php
if
(
empty
(
$sectionDiff
[
'added'
])
&&
empty
(
$sectionDiff
[
'removed'
])
&&
empty
(
$sectionDiff
[
'changed'
])
&&
empty
(
$sectionDiff
[
'count_changed'
]))
:
?>
<div
style=
"color:#059669;text-align:center;"
>
✓ لا توجد اختلافات
</div>
<?php
endif
;
?>
</div>
</div>
<?php
endforeach
;
?>
<?php
else
:
?>
<div
class=
"card"
style=
"padding:30px;text-align:center;color:#059669;"
>
<strong>
✓ لا توجد اختلافات في البيانات المرتبطة
</strong>
</div>
<?php
endif
;
?>
<?php
$__template
->
endSection
();
?>
\ No newline at end of file
app/Modules/Archive/Views/index.php
0 → 100644
View file @
1e79566a
<?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
app/Modules/Archive/Views/show.php
0 → 100644
View file @
1e79566a
<?php
$__template
->
layout
(
'Layout.main'
);
?>
<?php
$__template
->
section
(
'title'
);
?>
<?php
if
(
!
empty
(
$isChainView
))
:
?>
سلسلة ملكية رقم العضوية:
<?=
e
(
$membershipNumber
??
''
)
?>
<?php
else
:
?>
لقطة أرشيفية #
<?=
(
int
)
(
$snapshot
[
'id'
]
??
0
)
?>
<?php
endif
;
?>
<?php
$__template
->
endSection
();
?>
<?php
$__template
->
section
(
'page_actions'
);
?>
<a
href=
"/archive"
class=
"btn btn-outline"
>
← العودة للأرشيف
</a>
<?php
$__template
->
endSection
();
?>
<?php
$__template
->
section
(
'content'
);
?>
<?php
if
(
!
empty
(
$isChainView
))
:
?>
<!-- Number Chain View -->
<div
class=
"card"
style=
"margin-bottom:20px;"
>
<div
style=
"padding:15px 20px;border-bottom:1px solid #E5E7EB;"
>
<h3
style=
"margin:0;color:#0D7377;"
>
سلسلة ملكية رقم العضوية:
<?=
e
(
$membershipNumber
??
''
)
?>
</h3>
</div>
<div
style=
"padding:20px;"
>
<?php
if
(
empty
(
$numberChain
))
:
?>
<div
style=
"text-align:center;padding:30px;color:#6B7280;"
>
لا توجد سجلات في سلسلة الملكية
</div>
<?php
else
:
?>
<?php
foreach
(
$numberChain
as
$i
=>
$link
)
:
?>
<div
style=
"display:flex;gap:15px;padding:15px 0;
<?=
$i
<
count
(
$numberChain
)
-
1
?
'border-bottom:1px solid #F3F4F6;'
:
''
?>
"
>
<div
style=
"flex-shrink:0;width:40px;height:40px;border-radius:50%;background:
<?=
$link
[
'held_until'
]
===
null
?
'#059669'
:
'#E5E7EB'
?>
;display:flex;align-items:center;justify-content:center;color:
<?=
$link
[
'held_until'
]
===
null
?
'#fff'
:
'#6B7280'
?>
;font-weight:700;font-size:14px;"
>
<?=
$i
+
1
?>
</div>
<div
style=
"flex:1;"
>
<div
style=
"display:flex;justify-content:space-between;margin-bottom:5px;"
>
<strong
style=
"color:#1A1A2E;"
>
<?=
e
(
$link
[
'holder_type'
])
?>
</strong>
<?php
if
(
$link
[
'held_until'
]
===
null
)
:
?>
<span
style=
"background:#F0FDF4;color:#059669;padding:2px 8px;border-radius:4px;font-size:12px;"
>
الحالي
</span>
<?php
endif
;
?>
</div>
<div
style=
"font-size:13px;color:#6B7280;"
>
الكيان:
<code>
<?=
e
(
$link
[
'holder_entity_type'
])
?>
</code>
#
<?=
(
int
)
$link
[
'holder_entity_id'
]
?>
</div>
<div
style=
"font-size:12px;color:#9CA3AF;margin-top:4px;"
>
من:
<?=
e
(
$link
[
'held_from'
])
?>
<?php
if
(
$link
[
'held_until'
])
:
?>
— إلى:
<?=
e
(
$link
[
'held_until'
])
?>
<?php
else
:
?>
— حتى الآن
<?php
endif
;
?>
</div>
<?php
if
(
$link
[
'previous_holder_id'
])
:
?>
<div
style=
"font-size:12px;color:#9CA3AF;"
>
المالك السابق: سلسلة #
<?=
(
int
)
$link
[
'previous_holder_id'
]
?>
</div>
<?php
endif
;
?>
</div>
</div>
<?php
endforeach
;
?>
<?php
endif
;
?>
</div>
</div>
<?php
if
(
!
empty
(
$otherSnapshots
))
:
?>
<div
class=
"card"
>
<div
style=
"padding:15px 20px;border-bottom:1px solid #E5E7EB;"
>
<h3
style=
"margin:0;color:#0D7377;"
>
اللقطات الأرشيفية المرتبطة
</h3>
</div>
<div
class=
"table-responsive"
>
<table
class=
"data-table"
>
<thead>
<tr><th>
#
</th><th>
التاريخ
</th><th>
السبب
</th><th>
ملاحظات
</th><th>
الإجراءات
</th></tr>
</thead>
<tbody>
<?php
foreach
(
$otherSnapshots
as
$s
)
:
?>
<tr>
<td>
<?=
(
int
)
$s
[
'id'
]
?>
</td>
<td
style=
"font-size:12px;"
>
<?=
e
(
$s
[
'snapshot_taken_at'
])
?>
</td>
<td
style=
"font-weight:600;"
>
<?=
e
(
$s
[
'snapshot_reason'
])
?>
</td>
<td
style=
"font-size:12px;color:#6B7280;"
>
<?=
e
(
mb_substr
(
$s
[
'notes'
]
??
''
,
0
,
100
))
?:
'—'
?>
</td>
<td><a
href=
"/archive/
<?=
(
int
)
$s
[
'id'
]
?>
"
class=
"btn btn-sm btn-outline"
>
عرض
</a></td>
</tr>
<?php
endforeach
;
?>
</tbody>
</table>
</div>
</div>
<?php
endif
;
?>
<?php
else
:
?>
<!-- Single Snapshot View -->
<div
style=
"display:grid;grid-template-columns:1fr 300px;gap:20px;"
>
<div>
<!-- Snapshot Info -->
<div
class=
"card"
style=
"margin-bottom:20px;"
>
<div
style=
"padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;"
>
<h3
style=
"margin:0;color:#0D7377;"
>
معلومات اللقطة
</h3>
<span
style=
"background:#F3F4F6;padding:4px 12px;border-radius:4px;font-size:13px;font-weight:600;"
>
<?=
e
(
$snapshot
[
'snapshot_reason'
])
?>
</span>
</div>
<div
style=
"padding:20px;"
>
<table
style=
"width:100%;font-size:14px;"
>
<tr><td
style=
"padding:6px 0;color:#6B7280;width:35%;"
>
نوع الكيان
</td><td
style=
"padding:6px 0;"
><code>
<?=
e
(
$snapshot
[
'entity_type'
])
?>
</code></td></tr>
<tr><td
style=
"padding:6px 0;color:#6B7280;"
>
رقم الكيان
</td><td
style=
"padding:6px 0;"
>
<?=
(
int
)
$snapshot
[
'entity_id'
]
?>
</td></tr>
<?php
if
(
$snapshot
[
'membership_number'
])
:
?>
<tr><td
style=
"padding:6px 0;color:#6B7280;"
>
رقم العضوية
</td><td
style=
"padding:6px 0;"
><a
href=
"/archive/number-chain/
<?=
urlencode
(
$snapshot
[
'membership_number'
])
?>
"
style=
"color:#0D7377;font-weight:600;"
>
<?=
e
(
$snapshot
[
'membership_number'
])
?>
</a></td></tr>
<?php
endif
;
?>
<tr><td
style=
"padding:6px 0;color:#6B7280;"
>
تاريخ اللقطة
</td><td
style=
"padding:6px 0;"
>
<?=
e
(
$snapshot
[
'snapshot_taken_at'
])
?>
</td></tr>
<tr><td
style=
"padding:6px 0;color:#6B7280;"
>
بواسطة
</td><td
style=
"padding:6px 0;"
>
<?=
$snapshot
[
'snapshot_taken_by'
]
?
'#'
.
(
int
)
$snapshot
[
'snapshot_taken_by'
]
:
'النظام'
?>
</td></tr>
<?php
if
(
$snapshot
[
'notes'
])
:
?>
<tr><td
style=
"padding:6px 0;color:#6B7280;"
>
ملاحظات
</td><td
style=
"padding:6px 0;"
>
<?=
e
(
$snapshot
[
'notes'
])
?>
</td></tr>
<?php
endif
;
?>
</table>
</div>
</div>
<!-- Main Entity Data -->
<div
class=
"card"
style=
"margin-bottom:20px;"
>
<div
style=
"padding:15px 20px;border-bottom:1px solid #E5E7EB;"
>
<h3
style=
"margin:0;color:#0D7377;"
>
بيانات الكيان الرئيسية
</h3>
</div>
<div
style=
"padding:20px;"
>
<table
style=
"width:100%;font-size:13px;"
>
<?php
foreach
(
$snapshot
[
'full_data'
]
as
$key
=>
$value
)
:
?>
<tr
style=
"border-bottom:1px solid #F9FAFB;"
>
<td
style=
"padding:6px 0;color:#6B7280;width:35%;font-weight:500;"
>
<?=
e
(
$key
)
?>
</td>
<td
style=
"padding:6px 0;"
>
<?php
if
(
is_array
(
$value
))
:
?>
<code
style=
"font-size:11px;background:#F3F4F6;padding:2px 6px;border-radius:3px;"
>
<?=
e
(
json_encode
(
$value
,
JSON_UNESCAPED_UNICODE
))
?>
</code>
<?php
elseif
(
$value
===
null
)
:
?>
<span
style=
"color:#D1D5DB;"
>
NULL
</span>
<?php
elseif
(
mb_strlen
((
string
)
$value
)
>
100
)
:
?>
<details>
<summary
style=
"cursor:pointer;color:#0D7377;font-size:12px;"
>
عرض (
<?=
mb_strlen
((
string
)
$value
)
?>
حرف)
</summary>
<div
style=
"margin-top:5px;padding:8px;background:#F9FAFB;border-radius:4px;font-size:12px;word-break:break-all;"
>
<?=
e
((
string
)
$value
)
?>
</div>
</details>
<?php
else
:
?>
<?=
e
((
string
)
$value
)
?>
<?php
endif
;
?>
</td>
</tr>
<?php
endforeach
;
?>
</table>
</div>
</div>
<!-- Related Data -->
<?php
if
(
!
empty
(
$snapshot
[
'related_data'
]))
:
?>
<?php
foreach
(
$snapshot
[
'related_data'
]
as
$section
=>
$records
)
:
?>
<div
class=
"card"
style=
"margin-bottom:20px;"
>
<div
style=
"padding:15px 20px;border-bottom:1px solid #E5E7EB;"
>
<h3
style=
"margin:0;color:#0D7377;"
>
<?=
e
(
$section
)
?>
<span
style=
"color:#9CA3AF;font-size:14px;"
>
(
<?=
count
(
$records
)
?>
)
</span></h3>
</div>
<div
style=
"padding:20px;"
>
<?php
if
(
empty
(
$records
))
:
?>
<div
style=
"text-align:center;color:#6B7280;padding:15px;"
>
لا توجد سجلات
</div>
<?php
else
:
?>
<?php
foreach
(
$records
as
$idx
=>
$record
)
:
?>
<details
style=
"margin-bottom:10px;border:1px solid #E5E7EB;border-radius:6px;"
<?=
$idx
===
0
?
'open'
:
''
?>
>
<summary
style=
"padding:10px 15px;cursor:pointer;background:#F9FAFB;border-radius:6px;font-weight:600;font-size:13px;"
>
سجل #
<?=
$record
[
'id'
]
??
(
$idx
+
1
)
?>
<?php
if
(
isset
(
$record
[
'full_name_ar'
]))
:
?>
—
<?=
e
(
$record
[
'full_name_ar'
])
?>
<?php
endif
;
?>
</summary>
<div
style=
"padding:10px 15px;"
>
<table
style=
"width:100%;font-size:12px;"
>
<?php
foreach
(
$record
as
$rk
=>
$rv
)
:
?>
<tr>
<td
style=
"padding:3px 0;color:#6B7280;width:35%;"
>
<?=
e
(
$rk
)
?>
</td>
<td
style=
"padding:3px 0;"
>
<?php
if
(
$rv
===
null
)
:
?>
<span
style=
"color:#D1D5DB;"
>
NULL
</span>
<?php
elseif
(
is_array
(
$rv
))
:
?>
<code
style=
"font-size:10px;"
>
<?=
e
(
json_encode
(
$rv
,
JSON_UNESCAPED_UNICODE
))
?>
</code>
<?php
else
:
?>
<?=
e
((
string
)
$rv
)
?>
<?php
endif
;
?>
</td>
</tr>
<?php
endforeach
;
?>
</table>
</div>
</details>
<?php
endforeach
;
?>
<?php
endif
;
?>
</div>
</div>
<?php
endforeach
;
?>
<?php
endif
;
?>
</div>
<!-- Sidebar: Other Snapshots + Compare -->
<div>
<div
class=
"card"
style=
"margin-bottom:20px;"
>
<div
style=
"padding:12px 15px;border-bottom:1px solid #E5E7EB;"
>
<h4
style=
"margin:0;color:#1A1A2E;font-size:14px;"
>
لقطات أخرى لنفس الكيان
</h4>
</div>
<div
style=
"padding:10px 15px;"
>
<?php
if
(
empty
(
$otherSnapshots
)
||
count
(
$otherSnapshots
)
<=
1
)
:
?>
<div
style=
"color:#6B7280;font-size:13px;text-align:center;padding:10px;"
>
لا توجد لقطات أخرى
</div>
<?php
else
:
?>
<?php
foreach
(
$otherSnapshots
as
$os
)
:
?>
<div
style=
"padding:8px 0;border-bottom:1px solid #F3F4F6;font-size:13px;
<?=
(
int
)
$os
[
'id'
]
===
(
int
)
$snapshot
[
'id'
]
?
'background:#EFF6FF;margin:0 -15px;padding:8px 15px;'
:
''
?>
"
>
<div
style=
"display:flex;justify-content:space-between;align-items:center;"
>
<div>
<strong>
#
<?=
(
int
)
$os
[
'id'
]
?>
</strong>
<span
style=
"color:#6B7280;font-size:11px;display:block;"
>
<?=
e
(
$os
[
'snapshot_reason'
])
?>
</span>
</div>
<div
style=
"text-align:left;"
>
<?php
if
((
int
)
$os
[
'id'
]
!==
(
int
)
$snapshot
[
'id'
])
:
?>
<a
href=
"/archive/
<?=
(
int
)
$os
[
'id'
]
?>
"
style=
"color:#0D7377;font-size:12px;"
>
عرض
</a>
<br>
<a
href=
"/archive/compare/
<?=
(
int
)
$snapshot
[
'id'
]
?>
/
<?=
(
int
)
$os
[
'id'
]
?>
"
style=
"color:#D97706;font-size:11px;"
>
مقارنة
</a>
<?php
else
:
?>
<span
style=
"color:#059669;font-size:11px;font-weight:600;"
>
الحالي
</span>
<?php
endif
;
?>
</div>
</div>
<div
style=
"font-size:11px;color:#9CA3AF;margin-top:2px;"
>
<?=
e
(
$os
[
'snapshot_taken_at'
])
?>
</div>
</div>
<?php
endforeach
;
?>
<?php
endif
;
?>
</div>
</div>
<!-- Audit link -->
<div
class=
"card"
>
<div
style=
"padding:12px 15px;"
>
<a
href=
"/audit/entity/
<?=
urlencode
(
$snapshot
[
'entity_type'
])
?>
/
<?=
(
int
)
$snapshot
[
'entity_id'
]
?>
"
class=
"btn btn-outline"
style=
"width:100%;text-align:center;"
>
عرض سجل المراجعة للكيان
</a>
</div>
</div>
</div>
</div>
<?php
endif
;
?>
<?php
$__template
->
endSection
();
?>
\ No newline at end of file
app/Modules/Archive/bootstrap.php
0 → 100644
View file @
1e79566a
<?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
database/migrations/Phase_04_001_create_archive_snapshots_table.php
0 → 100644
View file @
1e79566a
<?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
database/migrations/Phase_04_002_create_membership_number_chain_table.php
0 → 100644
View file @
1e79566a
<?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
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment