Commit 3b4be9a4 authored by Fares's avatar Fares

test1

parent f548e948
<?php
require_once __DIR__ . '/../../config/db.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/AuditTrail.php';
require_once __DIR__ . '/../../includes/ViolationService.php';
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
requirePermission('blacklist.manage');
verifyCsrf();
try {
$userId = $_SESSION['user_id'];
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$errors = [];
if (empty($input['national_id'])) $errors[] = 'National ID is required';
if (empty($input['full_name_ar'])) $errors[] = 'Full name (Arabic) is required';
if (empty($input['reason'])) $errors[] = 'Reason is required';
// Validate national ID format (14 digits for Egyptian NID)
if (!empty($input['national_id']) && !preg_match('/^\d{14}$/', $input['national_id'])) {
$errors[] = 'National ID must be 14 digits';
}
if ($errors) {
http_response_code(422);
echo json_encode(['success' => false, 'errors' => $errors]);
exit;
}
$service = new ViolationService($pdo);
$id = $service->addToBlacklist($input, $userId);
AuditTrail::log($pdo, 'BLACKLIST_ADD', 'blacklist', $id, [
'national_id' => $input['national_id'],
'full_name_ar' => $input['full_name_ar'],
'reason' => $input['reason'],
'is_permanent' => $input['is_permanent'] ?? 1,
], $userId);
echo json_encode([
'success' => true,
'message' => 'Added to blacklist successfully',
'id' => $id,
]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/db.php';
require_once __DIR__ . '/../../includes/auth.php';
header('Content-Type: application/json; charset=utf-8');
requirePermission('blacklist.view');
try {
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = min(100, max(10, (int)($_GET['per_page'] ?? 25)));
$offset = ($page - 1) * $perPage;
$where = ["1=1"];
$params = [];
if (!empty($_GET['status'])) {
$where[] = "b.status = ?";
$params[] = $_GET['status'];
} else {
$where[] = "b.status = 'active'";
}
if (!empty($_GET['search'])) {
$s = '%' . $_GET['search'] . '%';
$where[] = "(b.full_name_ar LIKE ? OR b.national_id LIKE ? OR b.reason LIKE ?)";
$params[] = $s;
$params[] = $s;
$params[] = $s;
}
if (!empty($_GET['is_permanent'])) {
$where[] = "b.is_permanent = ?";
$params[] = (int) $_GET['is_permanent'];
}
$whereClause = implode(' AND ', $where);
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM blacklist b WHERE {$whereClause}");
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
$sql = "
SELECT
b.id, b.member_id, b.national_id, b.full_name_ar, b.full_name_en,
b.reason, b.blacklisted_at, b.is_permanent, b.review_date, b.status,
b.removed_at, b.removal_reason,
u1.full_name as blacklisted_by_name,
u2.full_name as removed_by_name,
m.membership_no
FROM blacklist b
LEFT JOIN users u1 ON u1.id = b.blacklisted_by
LEFT JOIN users u2 ON u2.id = b.removed_by
LEFT JOIN members m ON m.id = b.member_id
WHERE {$whereClause}
ORDER BY b.blacklisted_at DESC
LIMIT {$perPage} OFFSET {$offset}
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode([
'success' => true,
'data' => $rows,
'total' => $total,
'page' => $page,
'per_page'=> $perPage,
'pages' => ceil($total / $perPage),
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/db.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/AuditTrail.php';
require_once __DIR__ . '/../../includes/ViolationService.php';
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
requirePermission('blacklist.manage');
verifyCsrf();
try {
$userId = $_SESSION['user_id'];
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
if (empty($input['blacklist_id'])) {
http_response_code(422);
echo json_encode(['success' => false, 'message' => 'Blacklist ID is required']);
exit;
}
if (empty($input['removal_reason'])) {
http_response_code(422);
echo json_encode(['success' => false, 'message' => 'Removal reason is required']);
exit;
}
$service = new ViolationService($pdo);
$service->removeFromBlacklist((int) $input['blacklist_id'], $input['removal_reason'], $userId);
AuditTrail::log($pdo, 'BLACKLIST_REMOVE', 'blacklist', (int) $input['blacklist_id'], [
'removal_reason' => $input['removal_reason'],
], $userId);
echo json_encode([
'success' => true,
'message' => 'Removed from blacklist successfully',
]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
\ No newline at end of file
<?php
/**
* Batch Print API
* POST: Creates a print batch from pending queue items
* Permission: cards.print_queue
*/
require_once __DIR__ . '/../../config/bootstrap.php';
Security::requirePermission('cards.print_queue');
Security::requireMethod('POST');
try {
$db = Database::getInstance()->getConnection();
$user = Auth::getInstance()->getUser();
$input = json_decode(file_get_contents('php://input'), true);
$queueIds = $input['queue_ids'] ?? [];
if (!is_array($queueIds) || count($queueIds) === 0) {
throw new InvalidArgumentException('At least one queue item must be selected for batch printing.');
}
// Sanitize IDs
$queueIds = array_map('intval', $queueIds);
$queueIds = array_filter($queueIds, fn($id) => $id > 0);
if (count($queueIds) === 0) {
throw new InvalidArgumentException('No valid queue items provided.');
}
$db->beginTransaction();
// ── Verify all items are pending ──
$placeholders = implode(',', array_fill(0, count($queueIds), '?'));
$stmtCheck = $db->prepare("
SELECT pq.id, pq.status, pq.card_id, mc.status AS card_status, m.photo_path
FROM card_print_queue pq
INNER JOIN member_cards mc ON pq.card_id = mc.id
INNER JOIN members m ON mc.member_id = m.id
WHERE pq.id IN ({$placeholders})
FOR UPDATE
");
$stmtCheck->execute($queueIds);
$items = $stmtCheck->fetchAll(PDO::FETCH_ASSOC);
if (count($items) !== count($queueIds)) {
throw new InvalidArgumentException('Some queue items were not found.');
}
$blockers = [];
foreach ($items as $item) {
if ($item['status'] !== 'pending') {
$blockers[] = "Queue #{$item['id']} is not pending (status: {$item['status']})";
}
if (empty($item['photo_path'])) {
$blockers[] = "Queue #{$item['id']} — member has no photo";
}
}
// ── F7.09: Batch print must be reviewed (we enforce this at the UI level, but also validate) ──
if (count($blockers) > 0) {
throw new InvalidArgumentException("Batch has blockers:\n" . implode("\n", $blockers));
}
// ── Generate batch number ──
$batchNumber = 'BATCH-' . date('Ymd') . '-' . str_pad(mt_rand(1, 9999), 4, '0', STR_PAD_LEFT);
// ── Update all queue items ──
$stmtUpdateQueue = $db->prepare("
UPDATE card_print_queue
SET status = 'printing',
batch_number = :batch
WHERE id IN ({$placeholders})
");
$stmtUpdateQueue->execute(array_merge([$batchNumber], array_slice($queueIds, 0)));
// Rebuild for execute
$updateParams = $queueIds;
$stmtUpdateQueue2 = $db->prepare("
UPDATE card_print_queue
SET status = 'printing', batch_number = ?
WHERE id IN ({$placeholders})
");
$stmtUpdateQueue2->execute(array_merge([$batchNumber], $queueIds));
// ── Update card statuses to 'printing' ──
$cardIds = array_column($items, 'card_id');
$cardPlaceholders = implode(',', array_fill(0, count($cardIds), '?'));
$stmtUpdateCards = $db->prepare("
UPDATE member_cards
SET status = 'printing', updated_at = NOW()
WHERE id IN ({$cardPlaceholders}) AND status = 'requested'
");
$stmtUpdateCards->execute($cardIds);
// ── Audit trail ──
AuditTrail::log(
'batch_print_created',
'card_print_queue',
null,
null,
[
'batch_number' => $batchNumber,
'queue_ids' => $queueIds,
'card_count' => count($queueIds),
],
$user['id']
);
$db->commit();
echo json_encode([
'success' => true,
'message' => 'Print batch created with ' . count($queueIds) . ' card(s).',
'batch_number' => $batchNumber,
'card_count' => count($queueIds),
]);
} catch (InvalidArgumentException $e) {
if (isset($db) && $db->inTransaction()) $db->rollBack();
http_response_code(422);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
} catch (Exception $e) {
if (isset($db) && $db->inTransaction()) $db->rollBack();
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to create print batch.', 'detail' => $e->getMessage()]);
}
\ No newline at end of file
<?php
/**
* Card Cancellation API
* POST: Cancel/invalidate a card
* Permission: cards.cancel
*/
require_once __DIR__ . '/../../config/bootstrap.php';
Security::requirePermission('cards.cancel');
Security::requireMethod('POST');
try {
$db = Database::getInstance()->getConnection();
$user = Auth::getInstance()->getUser();
$input = json_decode(file_get_contents('php://input'), true);
$cardId = intval($input['card_id'] ?? 0);
$reason = trim($input['reason'] ?? '');
if ($cardId <= 0) {
throw new InvalidArgumentException('Card ID is required.');
}
if ($reason === '') {
throw new InvalidArgumentException('Cancellation reason is required.');
}
$db->beginTransaction();
$stmtCard = $db->prepare("
SELECT id, card_number, member_id, status
FROM member_cards
WHERE id = :id
FOR UPDATE
");
$stmtCard->execute([':id' => $cardId]);
$card = $stmtCard->fetch(PDO::FETCH_ASSOC);
if (!$card) {
throw new InvalidArgumentException('Card not found.');
}
if ($card['status'] === 'cancelled') {
throw new InvalidArgumentException('Card is already cancelled.');
}
$oldStatus = $card['status'];
// ── Cancel the card ──
$stmtUpdate = $db->prepare("
UPDATE member_cards
SET status = 'cancelled',
cancelled_at = NOW(),
cancel_reason = :reason,
updated_at = NOW()
WHERE id = :id
");
$stmtUpdate->execute([
':reason' => $reason,
':id' => $cardId,
]);
// ── Remove from print queue if pending ──
$stmtQueueClean = $db->prepare("
UPDATE card_print_queue
SET status = 'error'
WHERE card_id = :card_id AND status IN ('pending', 'printing')
");
$stmtQueueClean->execute([':card_id' => $cardId]);
// ── Audit trail ──
AuditTrail::log(
'card_cancelled',
'member_cards',
$cardId,
['status' => $oldStatus],
['status' => 'cancelled', 'cancel_reason' => $reason],
$user['id']
);
$db->commit();
echo json_encode([
'success' => true,
'message' => 'Card cancelled successfully.',
'card_id' => (int)$cardId,
'card_number' => $card['card_number'],
]);
} catch (InvalidArgumentException $e) {
if (isset($db) && $db->inTransaction()) $db->rollBack();
http_response_code(422);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
} catch (Exception $e) {
if (isset($db) && $db->inTransaction()) $db->rollBack();
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to cancel card.', 'detail' => $e->getMessage()]);
}
\ No newline at end of file
<?php
/**
* Card Issue API
* POST: Activates a requested/printing card
* Permission: cards.issue
*/
require_once __DIR__ . '/../../config/bootstrap.php';
Security::requirePermission('cards.issue');
Security::requireMethod('POST');
try {
$db = Database::getInstance()->getConnection();
$user = Auth::getInstance()->getUser();
$input = json_decode(file_get_contents('php://input'), true);
$cardId = intval($input['card_id'] ?? 0);
if ($cardId <= 0) {
throw new InvalidArgumentException('Card ID is required.');
}
// ── Fetch card ──
$stmtCard = $db->prepare("
SELECT mc.*, m.application_status AS member_status, m.membership_number
FROM member_cards mc
INNER JOIN members m ON mc.member_id = m.id
WHERE mc.id = :id
FOR UPDATE
");
$db->beginTransaction();
$stmtCard->execute([':id' => $cardId]);
$card = $stmtCard->fetch(PDO::FETCH_ASSOC);
if (!$card) {
throw new InvalidArgumentException('Card not found.');
}
// ── Validate current status ──
if (!in_array($card['status'], ['requested', 'printing'])) {
throw new InvalidArgumentException(
'Card cannot be issued. Current status: ' . $card['status'] . '. Only "requested" or "printing" cards can be issued.'
);
}
// ── F7.01: Member must be active ──
if ($card['member_status'] !== 'activated') {
throw new InvalidArgumentException('Cannot issue card — member is not active. Member status: ' . $card['member_status']);
}
// ── F7.03: Check card fee paid ──
$stmtFeeCheck = $db->prepare("
SELECT mf.id, mf.status FROM member_fees mf
INNER JOIN fee_structures fs ON mf.fee_structure_id = fs.id
WHERE mf.member_id = :member_id
AND fs.fee_code = 'CARD_ISSUE'
AND mf.description LIKE :card_ref
ORDER BY mf.created_at DESC
LIMIT 1
");
$stmtFeeCheck->execute([
':member_id' => $card['member_id'],
':card_ref' => '%' . $card['card_number'] . '%',
]);
$cardFee = $stmtFeeCheck->fetch(PDO::FETCH_ASSOC);
if ($cardFee && $cardFee['status'] === 'pending') {
throw new InvalidArgumentException('Card fee has not been paid yet. Fee must be settled before issuance.');
}
// ── If dependent card, check dependent is still active ──
if ($card['dependent_id']) {
$stmtDep = $db->prepare("SELECT status FROM member_dependents WHERE id = :id");
$stmtDep->execute([':id' => $card['dependent_id']]);
$dep = $stmtDep->fetch(PDO::FETCH_ASSOC);
if (!$dep || $dep['status'] !== 'active') {
throw new InvalidArgumentException('Dependent is no longer active. Cannot issue card.');
}
}
$oldStatus = $card['status'];
// ── Update card to active ──
$stmtUpdate = $db->prepare("
UPDATE member_cards
SET status = 'active',
printed_at = COALESCE(printed_at, NOW()),
updated_at = NOW()
WHERE id = :id
");
$stmtUpdate->execute([':id' => $cardId]);
// ── Update print queue if exists ──
$stmtQueueUpdate = $db->prepare("
UPDATE card_print_queue
SET status = 'printed', printed_at = NOW(), printed_by = :user_id
WHERE card_id = :card_id AND status IN ('pending', 'printing')
");
$stmtQueueUpdate->execute([':card_id' => $cardId, ':user_id' => $user['id']]);
// ── Audit trail ──
AuditTrail::log(
'card_issued',
'member_cards',
$cardId,
['status' => $oldStatus],
['status' => 'active', 'issued_by' => $user['id']],
$user['id']
);
$db->commit();
echo json_encode([
'success' => true,
'message' => 'Card issued and activated successfully.',
'card_id' => (int)$cardId,
'card_number' => $card['card_number'],
]);
} catch (InvalidArgumentException $e) {
if (isset($db) && $db->inTransaction()) $db->rollBack();
http_response_code(422);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
} catch (Exception $e) {
if (isset($db) && $db->inTransaction()) $db->rollBack();
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to issue card.', 'detail' => $e->getMessage()]);
}
\ No newline at end of file
<?php
/**
* Card List API — Server-side DataTables
* GET: Returns paginated, filtered, sorted card records
* Permission: cards.view
*/
require_once __DIR__ . '/../../config/bootstrap.php';
Security::requirePermission('cards.view');
try {
$db = Database::getInstance()->getConnection();
// DataTables parameters
$draw = intval($_GET['draw'] ?? 1);
$start = intval($_GET['start'] ?? 0);
$length = intval($_GET['length'] ?? 25);
$search = trim($_GET['search']['value'] ?? '');
$orderCol = intval($_GET['order'][0]['column'] ?? 0);
$orderDir = strtolower($_GET['order'][0]['dir'] ?? 'desc') === 'asc' ? 'ASC' : 'DESC';
// Filters
$filterStatus = trim($_GET['filter_status'] ?? '');
$filterCardType = trim($_GET['filter_card_type'] ?? '');
$filterDateFrom = trim($_GET['filter_date_from'] ?? '');
$filterDateTo = trim($_GET['filter_date_to'] ?? '');
$filterMemberId = intval($_GET['filter_member_id'] ?? 0);
$columns = [
'mc.card_number',
'm.membership_number',
'COALESCE(m.full_name_ar, m.full_name_en)',
'ct.name_en',
'mc.status',
'mc.issue_date',
'mc.expiry_date',
'mc.created_at'
];
$orderByColumn = $columns[$orderCol] ?? 'mc.created_at';
$baseQuery = "
FROM member_cards mc
INNER JOIN members m ON mc.member_id = m.id
LEFT JOIN card_types ct ON mc.card_type_id = ct.id
LEFT JOIN member_dependents md ON mc.dependent_id = md.id
WHERE 1=1
";
$params = [];
if ($search !== '') {
$baseQuery .= " AND (
mc.card_number LIKE :search
OR m.membership_number LIKE :search2
OR m.full_name_ar LIKE :search3
OR m.full_name_en LIKE :search4
OR mc.barcode_data LIKE :search5
)";
$searchParam = "%{$search}%";
$params[':search'] = $searchParam;
$params[':search2'] = $searchParam;
$params[':search3'] = $searchParam;
$params[':search4'] = $searchParam;
$params[':search5'] = $searchParam;
}
if ($filterStatus !== '') {
$baseQuery .= " AND mc.status = :status";
$params[':status'] = $filterStatus;
}
if ($filterCardType !== '') {
$baseQuery .= " AND mc.card_type_id = :card_type_id";
$params[':card_type_id'] = $filterCardType;
}
if ($filterDateFrom !== '') {
$baseQuery .= " AND mc.issue_date >= :date_from";
$params[':date_from'] = $filterDateFrom;
}
if ($filterDateTo !== '') {
$baseQuery .= " AND mc.issue_date <= :date_to";
$params[':date_to'] = $filterDateTo;
}
if ($filterMemberId > 0) {
$baseQuery .= " AND mc.member_id = :member_id";
$params[':member_id'] = $filterMemberId;
}
// Total records (unfiltered)
$stmtTotal = $db->prepare("SELECT COUNT(*) FROM member_cards mc INNER JOIN members m ON mc.member_id = m.id");
$stmtTotal->execute();
$totalRecords = $stmtTotal->fetchColumn();
// Filtered count
$stmtFiltered = $db->prepare("SELECT COUNT(*) {$baseQuery}");
$stmtFiltered->execute($params);
$filteredRecords = $stmtFiltered->fetchColumn();
// Data query
$dataQuery = "
SELECT
mc.id,
mc.card_number,
mc.member_id,
mc.dependent_id,
mc.card_type_id,
mc.issue_date,
mc.expiry_date,
mc.status,
mc.barcode_data,
mc.previous_card_id,
mc.printed_at,
mc.cancelled_at,
mc.cancel_reason,
mc.created_at,
m.membership_number,
m.full_name_ar AS member_name_ar,
m.full_name_en AS member_name_en,
m.application_status AS member_status,
ct.name_en AS card_type_en,
ct.name_ar AS card_type_ar,
md.full_name_ar AS dependent_name_ar,
md.full_name_en AS dependent_name_en
{$baseQuery}
ORDER BY {$orderByColumn} {$orderDir}
LIMIT :offset, :limit
";
$stmtData = $db->prepare($dataQuery);
foreach ($params as $key => $val) {
$stmtData->bindValue($key, $val);
}
$stmtData->bindValue(':offset', $start, PDO::PARAM_INT);
$stmtData->bindValue(':limit', $length, PDO::PARAM_INT);
$stmtData->execute();
$records = $stmtData->fetchAll(PDO::FETCH_ASSOC);
$data = [];
foreach ($records as $row) {
$holderName = $row['dependent_id']
? ($row['dependent_name_ar'] ?: $row['dependent_name_en'])
: ($row['member_name_ar'] ?: $row['member_name_en']);
$data[] = [
'id' => (int)$row['id'],
'card_number' => $row['card_number'],
'member_id' => (int)$row['member_id'],
'membership_number'=> $row['membership_number'],
'holder_name' => $holderName,
'is_dependent' => !empty($row['dependent_id']),
'dependent_id' => $row['dependent_id'] ? (int)$row['dependent_id'] : null,
'card_type_en' => $row['card_type_en'],
'card_type_ar' => $row['card_type_ar'],
'status' => $row['status'],
'issue_date' => $row['issue_date'],
'expiry_date' => $row['expiry_date'],
'barcode_data' => $row['barcode_data'],
'printed_at' => $row['printed_at'],
'cancelled_at' => $row['cancelled_at'],
'cancel_reason' => $row['cancel_reason'],
'previous_card_id' => $row['previous_card_id'] ? (int)$row['previous_card_id'] : null,
'created_at' => $row['created_at'],
];
}
echo json_encode([
'draw' => $draw,
'recordsTotal' => (int)$totalRecords,
'recordsFiltered' => (int)$filteredRecords,
'data' => $data,
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => 'Failed to retrieve cards list.', 'detail' => $e->getMessage()]);
}
\ No newline at end of file
<?php
/**
* Mark Printed API
* POST: Mark a batch or individual cards as printed
* Permission: cards.print_queue
*/
require_once __DIR__ . '/../../config/bootstrap.php';
Security::requirePermission('cards.print_queue');
Security::requireMethod('POST');
try {
$db = Database::getInstance()->getConnection();
$user = Auth::getInstance()->getUser();
$input = json_decode(file_get_contents('php://input'), true);
$batchNumber = trim($input['batch_number'] ?? '');
$queueIds = $input['queue_ids'] ?? [];
if ($batchNumber === '' && (empty($queueIds) || !is_array($queueIds))) {
throw new InvalidArgumentException('Either batch_number or queue_ids is required.');
}
$db->beginTransaction();
if ($batchNumber !== '') {
// ── Mark entire batch as printed ──
$stmtFetch = $db->prepare("
SELECT pq.id, pq.card_id
FROM card_print_queue pq
WHERE pq.batch_number = :batch AND pq.status = 'printing'
");
$stmtFetch->execute([':batch' => $batchNumber]);
$items = $stmtFetch->fetchAll(PDO::FETCH_ASSOC);
if (count($items) === 0) {
throw new InvalidArgumentException('No items found in this batch with "printing" status.');
}
$queueIdsToMark = array_column($items, 'id');
$cardIdsToMark = array_column($items, 'card_id');
} else {
// ── Mark specific queue items ──
$queueIds = array_map('intval', $queueIds);
$queueIds = array_filter($queueIds, fn($id) => $id > 0);
$placeholders = implode(',', array_fill(0, count($queueIds), '?'));
$stmtFetch = $db->prepare("
SELECT pq.id, pq.card_id
FROM card_print_queue pq
WHERE pq.id IN ({$placeholders}) AND pq.status = 'printing'
");
$stmtFetch->execute($queueIds);
$items = $stmtFetch->fetchAll(PDO::FETCH_ASSOC);
$queueIdsToMark = array_column($items, 'id');
$cardIdsToMark = array_column($items, 'card_id');
}
if (count($queueIdsToMark) === 0) {
throw new InvalidArgumentException('No eligible items to mark as printed.');
}
// ── Update print queue ──
$qPlaceholders = implode(',', array_fill(0, count($queueIdsToMark), '?'));
$stmtUpdateQ = $db->prepare("
UPDATE card_print_queue
SET status = 'printed', printed_at = NOW(), printed_by = ?
WHERE id IN ({$qPlaceholders})
");
$stmtUpdateQ->execute(array_merge([$user['id']], $queueIdsToMark));
// ── Update cards to active ──
$cPlaceholders = implode(',', array_fill(0, count($cardIdsToMark), '?'));
$stmtUpdateC = $db->prepare("
UPDATE member_cards
SET status = 'active', printed_at = NOW(), updated_at = NOW()
WHERE id IN ({$cPlaceholders}) AND status IN ('requested', 'printing')
");
$stmtUpdateC->execute($cardIdsToMark);
// ── Audit trail ──
AuditTrail::log(
'cards_marked_printed',
'card_print_queue',
null,
null,
[
'batch_number' => $batchNumber ?: null,
'queue_ids' => $queueIdsToMark,
'card_ids' => $cardIdsToMark,
'count' => count($queueIdsToMark),
'marked_by' => $user['id'],
],
$user['id']
);
$db->commit();
echo json_encode([
'success' => true,
'message' => count($queueIdsToMark) . ' card(s) marked as printed and activated.',
'count' => count($queueIdsToMark),
]);
} catch (InvalidArgumentException $e) {
if (isset($db) && $db->inTransaction()) $db->rollBack();
http_response_code(422);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
} catch (Exception $e) {
if (isset($db) && $db->inTransaction()) $db->rollBack();
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to mark as printed.', 'detail' => $e->getMessage()]);
}
\ No newline at end of file
<?php
/**
* Print Queue List API — Server-side DataTables
* GET: Returns paginated print queue records
* Permission: cards.print_queue
*/
require_once __DIR__ . '/../../config/bootstrap.php';
Security::requirePermission('cards.print_queue');
try {
$db = Database::getInstance()->getConnection();
$draw = intval($_GET['draw'] ?? 1);
$start = intval($_GET['start'] ?? 0);
$length = intval($_GET['length'] ?? 25);
$search = trim($_GET['search']['value'] ?? '');
$orderCol = intval($_GET['order'][0]['column'] ?? 0);
$orderDir = strtolower($_GET['order'][0]['dir'] ?? 'asc') === 'asc' ? 'ASC' : 'DESC';
$filterStatus = trim($_GET['filter_status'] ?? '');
$filterBatch = trim($_GET['filter_batch'] ?? '');
$columns = [
'pq.id',
'mc.card_number',
'COALESCE(m.full_name_ar, m.full_name_en)',
'ct.name_en',
'pq.priority',
'pq.status',
'pq.batch_number',
'pq.created_at'
];
$orderByColumn = $columns[$orderCol] ?? 'pq.created_at';
$baseQuery = "
FROM card_print_queue pq
INNER JOIN member_cards mc ON pq.card_id = mc.id
INNER JOIN members m ON mc.member_id = m.id
LEFT JOIN card_types ct ON mc.card_type_id = ct.id
LEFT JOIN member_dependents md ON mc.dependent_id = md.id
WHERE 1=1
";
$params = [];
if ($search !== '') {
$baseQuery .= " AND (
mc.card_number LIKE :search
OR m.membership_number LIKE :search2
OR m.full_name_ar LIKE :search3
OR m.full_name_en LIKE :search4
OR pq.batch_number LIKE :search5
)";
$sp = "%{$search}%";
$params[':search'] = $sp;
$params[':search2'] = $sp;
$params[':search3'] = $sp;
$params[':search4'] = $sp;
$params[':search5'] = $sp;
}
if ($filterStatus !== '') {
$baseQuery .= " AND pq.status = :pq_status";
$params[':pq_status'] = $filterStatus;
}
if ($filterBatch !== '') {
$baseQuery .= " AND pq.batch_number = :batch";
$params[':batch'] = $filterBatch;
}
// Total
$stmtTotal = $db->prepare("SELECT COUNT(*) FROM card_print_queue pq INNER JOIN member_cards mc ON pq.card_id = mc.id INNER JOIN members m ON mc.member_id = m.id");
$stmtTotal->execute();
$totalRecords = $stmtTotal->fetchColumn();
// Filtered
$stmtFiltered = $db->prepare("SELECT COUNT(*) {$baseQuery}");
$stmtFiltered->execute($params);
$filteredRecords = $stmtFiltered->fetchColumn();
// Data
$dataQuery = "
SELECT
pq.id AS queue_id,
pq.card_id,
pq.priority,
pq.status AS queue_status,
pq.batch_number,
pq.printed_at AS queue_printed_at,
pq.printed_by,
pq.created_at AS queue_created_at,
mc.card_number,
mc.member_id,
mc.dependent_id,
mc.status AS card_status,
mc.issue_date,
mc.expiry_date,
m.membership_number,
m.full_name_ar AS member_name_ar,
m.full_name_en AS member_name_en,
m.photo_path,
ct.name_en AS card_type_en,
ct.name_ar AS card_type_ar,
md.full_name_ar AS dep_name_ar,
md.full_name_en AS dep_name_en
{$baseQuery}
ORDER BY {$orderByColumn} {$orderDir}
LIMIT :offset, :limit
";
$stmtData = $db->prepare($dataQuery);
foreach ($params as $key => $val) {
$stmtData->bindValue($key, $val);
}
$stmtData->bindValue(':offset', $start, PDO::PARAM_INT);
$stmtData->bindValue(':limit', $length, PDO::PARAM_INT);
$stmtData->execute();
$data = [];
foreach ($stmtData->fetchAll(PDO::FETCH_ASSOC) as $row) {
$holderName = $row['dependent_id']
? ($row['dep_name_ar'] ?: $row['dep_name_en'])
: ($row['member_name_ar'] ?: $row['member_name_en']);
$data[] = [
'queue_id' => (int)$row['queue_id'],
'card_id' => (int)$row['card_id'],
'card_number' => $row['card_number'],
'membership_number'=> $row['membership_number'],
'holder_name' => $holderName,
'is_dependent' => !empty($row['dependent_id']),
'card_type_en' => $row['card_type_en'],
'card_type_ar' => $row['card_type_ar'],
'card_status' => $row['card_status'],
'queue_status' => $row['queue_status'],
'priority' => (int)$row['priority'],
'batch_number' => $row['batch_number'],
'has_photo' => !empty($row['photo_path']),
'issue_date' => $row['issue_date'],
'expiry_date' => $row['expiry_date'],
'printed_at' => $row['queue_printed_at'],
'created_at' => $row['queue_created_at'],
];
}
echo json_encode([
'draw' => $draw,
'recordsTotal' => (int)$totalRecords,
'recordsFiltered' => (int)$filteredRecords,
'data' => $data,
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => 'Failed to retrieve print queue.', 'detail' => $e->getMessage()]);
}
\ No newline at end of file
<?php
/**
* Card Replacement API
* POST: Replace a lost or damaged card
* Permission: cards.replace
*/
require_once __DIR__ . '/../../config/bootstrap.php';
Security::requirePermission('cards.replace');
Security::requireMethod('POST');
try {
$db = Database::getInstance()->getConnection();
$user = Auth::getInstance()->getUser();
$input = json_decode(file_get_contents('php://input'), true);
$oldCardId = intval($input['card_id'] ?? 0);
$reason = trim($input['reason'] ?? ''); // 'lost' or 'damaged'
$reasonDetails = trim($input['reason_details'] ?? '');
$cardTypeId = intval($input['card_type_id'] ?? 0); // optional override
$policeReportRef = trim($input['police_report_ref'] ?? '');
if ($oldCardId <= 0) {
throw new InvalidArgumentException('Original card ID is required.');
}
if (!in_array($reason, ['lost', 'damaged'])) {
throw new InvalidArgumentException('Replacement reason must be "lost" or "damaged".');
}
// ── Fetch original card ──
$db->beginTransaction();
$stmtOld = $db->prepare("
SELECT mc.*, m.application_status AS member_status, m.membership_number,
m.full_name_ar, m.full_name_en, m.photo_path
FROM member_cards mc
INNER JOIN members m ON mc.member_id = m.id
WHERE mc.id = :id
FOR UPDATE
");
$stmtOld->execute([':id' => $oldCardId]);
$oldCard = $stmtOld->fetch(PDO::FETCH_ASSOC);
if (!$oldCard) {
throw new InvalidArgumentException('Original card not found.');
}
// ── Cannot replace an already cancelled/expired card ──
if (in_array($oldCard['status'], ['cancelled'])) {
throw new InvalidArgumentException('Cannot replace a card that is already cancelled.');
}
// ── F7.01: Member must be active ──
if ($oldCard['member_status'] !== 'activated') {
throw new InvalidArgumentException('Cannot replace card — member is not active.');
}
// ── F6.10: Police report check for lost cards (configurable) ──
if ($reason === 'lost') {
$stmtConfig = $db->prepare("
SELECT config_value FROM system_configurations
WHERE config_key = 'require_police_report_for_lost_card'
LIMIT 1
");
$stmtConfig->execute();
$configRow = $stmtConfig->fetch(PDO::FETCH_ASSOC);
$requirePolice = ($configRow && $configRow['config_value'] === '1');
if ($requirePolice && empty($policeReportRef)) {
throw new InvalidArgumentException('Police report reference is required for lost card replacement.');
}
}
// ── F7.04: Invalidate old card ──
$stmtInvalidate = $db->prepare("
UPDATE member_cards
SET status = :new_status,
cancelled_at = NOW(),
cancel_reason = :reason,
updated_at = NOW()
WHERE id = :id
");
$cancelReason = ucfirst($reason) . ' — replaced. ' . $reasonDetails;
if ($policeReportRef) {
$cancelReason .= ' Police Report: ' . $policeReportRef;
}
$stmtInvalidate->execute([
':new_status' => $reason, // 'lost' or 'damaged'
':reason' => $cancelReason,
':id' => $oldCardId,
]);
// ── Generate new card number (F7.06: must be different serial) ──
$newCardNumber = NumberSeries::getNext('CARD', $db);
// ── Use same card type unless overridden ──
$newCardTypeId = $cardTypeId > 0 ? $cardTypeId : $oldCard['card_type_id'];
// ── Fetch card type for validity ──
$stmtCT = $db->prepare("SELECT validity_months FROM card_types WHERE id = :id");
$stmtCT->execute([':id' => $newCardTypeId]);
$cardTypeData = $stmtCT->fetch(PDO::FETCH_ASSOC);
$validityMonths = intval($cardTypeData['validity_months'] ?? 12);
$issueDate = date('Y-m-d');
$expiryDate = date('Y-m-d', strtotime("+{$validityMonths} months"));
// ── Generate barcode data ──
$barcodeData = json_encode([
'cn' => $newCardNumber,
'mn' => $oldCard['membership_number'],
'mid' => $oldCard['member_id'],
'did' => $oldCard['dependent_id'],
'exp' => $expiryDate,
'rpl' => $oldCard['card_number'],
'chk' => substr(hash('sha256', $newCardNumber . $oldCard['member_id'] . $expiryDate . APP_SECRET), 0, 8),
]);
// ── Insert replacement card ──
$stmtInsert = $db->prepare("
INSERT INTO member_cards (
card_number, member_id, dependent_id, card_type_id,
issue_date, expiry_date, status, barcode_data,
previous_card_id, created_by, created_at, updated_at
) VALUES (
:card_number, :member_id, :dependent_id, :card_type_id,
:issue_date, :expiry_date, 'requested', :barcode_data,
:previous_card_id, :created_by, NOW(), NOW()
)
");
$stmtInsert->execute([
':card_number' => $newCardNumber,
':member_id' => $oldCard['member_id'],
':dependent_id' => $oldCard['dependent_id'],
':card_type_id' => $newCardTypeId,
':issue_date' => $issueDate,
':expiry_date' => $expiryDate,
':barcode_data' => $barcodeData,
':previous_card_id'=> $oldCardId,
':created_by' => $user['id'],
]);
$newCardId = $db->lastInsertId();
// ── Add to print queue ──
$stmtQueue = $db->prepare("
INSERT INTO card_print_queue (card_id, priority, status, created_at)
VALUES (:card_id, 3, 'pending', NOW())
");
$stmtQueue->execute([':card_id' => $newCardId]);
// ── Charge replacement fee ──
$feeCode = ($reason === 'lost') ? 'CARD_REPLACE_LOST' : 'CARD_REPLACE_DAMAGED';
$stmtFee = $db->prepare("
SELECT id, amount FROM fee_structures
WHERE fee_code = :code AND is_active = 1
LIMIT 1
");
$stmtFee->execute([':code' => $feeCode]);
$fee = $stmtFee->fetch(PDO::FETCH_ASSOC);
// Fallback to generic replacement fee
if (!$fee) {
$stmtFee->execute([':code' => 'CARD_REPLACEMENT']);
$fee = $stmtFee->fetch(PDO::FETCH_ASSOC);
}
$feeId = null;
if ($fee && $fee['amount'] > 0) {
$stmtCharge = $db->prepare("
INSERT INTO member_fees (
member_id, fee_structure_id, amount, fee_date,
status, description, created_by, created_at
) VALUES (
:member_id, :fee_id, :amount, CURDATE(),
'pending', :description, :created_by, NOW()
)
");
$feeDesc = ucfirst($reason) . ' card replacement fee — New Card #' . $newCardNumber . ' (replaces #' . $oldCard['card_number'] . ')';
$stmtCharge->execute([
':member_id' => $oldCard['member_id'],
':fee_id' => $fee['id'],
':amount' => $fee['amount'],
':description' => $feeDesc,
':created_by' => $user['id'],
]);
$feeId = $db->lastInsertId();
}
// ── Audit trail for invalidation ──
AuditTrail::log(
'card_invalidated',
'member_cards',
$oldCardId,
['status' => $oldCard['status']],
['status' => $reason, 'cancel_reason' => $cancelReason, 'replaced_by' => $newCardNumber],
$user['id']
);
// ── Audit trail for new card ──
AuditTrail::log(
'card_replacement_requested',
'member_cards',
$newCardId,
null,
[
'card_number' => $newCardNumber,
'replaces' => $oldCard['card_number'],
'reason' => $reason,
'member_id' => $oldCard['member_id'],
'fee_charged' => $fee['amount'] ?? 0,
'police_report' => $policeReportRef ?: null,
],
$user['id']
);
$db->commit();
echo json_encode([
'success' => true,
'message' => 'Card replacement processed. Old card invalidated. New card queued for printing.',
'new_card_id' => (int)$newCardId,
'new_card_number' => $newCardNumber,
'old_card_number' => $oldCard['card_number'],
'old_card_status' => $reason,
'fee_id' => $feeId ? (int)$feeId : null,
'fee_amount' => $fee['amount'] ?? 0,
]);
} catch (InvalidArgumentException $e) {
if (isset($db) && $db->inTransaction()) $db->rollBack();
http_response_code(422);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
} catch (Exception $e) {
if (isset($db) && $db->inTransaction()) $db->rollBack();
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to process card replacement.', 'detail' => $e->getMessage()]);
}
\ No newline at end of file
<?php
/**
* Card Request API
* POST: Creates a new card request for a member or dependent
* Permission: cards.request
*/
require_once __DIR__ . '/../../config/bootstrap.php';
Security::requirePermission('cards.request');
Security::requireMethod('POST');
try {
$db = Database::getInstance()->getConnection();
$user = Auth::getInstance()->getUser();
$input = json_decode(file_get_contents('php://input'), true);
$memberId = intval($input['member_id'] ?? 0);
$dependentId = !empty($input['dependent_id']) ? intval($input['dependent_id']) : null;
$cardTypeId = intval($input['card_type_id'] ?? 0);
// ── Validate member exists ──
if ($memberId <= 0) {
throw new InvalidArgumentException('Member ID is required.');
}
$stmtMember = $db->prepare("
SELECT id, membership_number, application_status, full_name_ar, full_name_en, photo_path
FROM members WHERE id = :id
");
$stmtMember->execute([':id' => $memberId]);
$member = $stmtMember->fetch(PDO::FETCH_ASSOC);
if (!$member) {
throw new InvalidArgumentException('Member not found.');
}
// ── F7.01: Card cannot be issued to a non-active member ──
if ($member['application_status'] !== 'activated') {
throw new InvalidArgumentException('Cannot request card for a non-active member. Current status: ' . $member['application_status']);
}
// ── F7.02: Card cannot be issued without a photo ──
if (empty($member['photo_path'])) {
throw new InvalidArgumentException('Member must have a photo on file before requesting a card.');
}
// ── Validate card type ──
if ($cardTypeId <= 0) {
throw new InvalidArgumentException('Card type is required.');
}
$stmtCT = $db->prepare("SELECT id, name_en, validity_months FROM card_types WHERE id = :id AND is_active = 1");
$stmtCT->execute([':id' => $cardTypeId]);
$cardType = $stmtCT->fetch(PDO::FETCH_ASSOC);
if (!$cardType) {
throw new InvalidArgumentException('Invalid or inactive card type.');
}
// ── If dependent card, validate dependent ──
if ($dependentId) {
$stmtDep = $db->prepare("
SELECT id, full_name_ar, full_name_en, status, photo_path
FROM member_dependents
WHERE id = :dep_id AND member_id = :member_id
");
$stmtDep->execute([':dep_id' => $dependentId, ':member_id' => $memberId]);
$dependent = $stmtDep->fetch(PDO::FETCH_ASSOC);
if (!$dependent) {
throw new InvalidArgumentException('Dependent not found or does not belong to this member.');
}
if ($dependent['status'] !== 'active') {
throw new InvalidArgumentException('Dependent is not active. Status: ' . $dependent['status']);
}
if (empty($dependent['photo_path'])) {
throw new InvalidArgumentException('Dependent must have a photo on file before requesting a card.');
}
}
// ── Check for existing active card ──
$stmtExisting = $db->prepare("
SELECT id, card_number FROM member_cards
WHERE member_id = :member_id
AND (:dep_id IS NULL AND dependent_id IS NULL OR dependent_id = :dep_id2)
AND status IN ('requested','printing','active')
");
$stmtExisting->execute([
':member_id' => $memberId,
':dep_id' => $dependentId,
':dep_id2' => $dependentId,
]);
$existingCard = $stmtExisting->fetch(PDO::FETCH_ASSOC);
if ($existingCard) {
throw new InvalidArgumentException(
'An active or pending card already exists (Card #' . $existingCard['card_number'] . '). Cancel or replace it first.'
);
}
// ── Generate card number ──
$cardNumber = NumberSeries::getNext('CARD', $db);
// ── Calculate dates ──
$issueDate = date('Y-m-d');
$validityMonths = intval($cardType['validity_months'] ?? 12);
$expiryDate = date('Y-m-d', strtotime("+{$validityMonths} months"));
// ── Generate barcode data ──
$barcodeData = json_encode([
'cn' => $cardNumber,
'mn' => $member['membership_number'],
'mid' => $memberId,
'did' => $dependentId,
'exp' => $expiryDate,
'chk' => substr(hash('sha256', $cardNumber . $memberId . $expiryDate . APP_SECRET), 0, 8),
]);
// ── Insert card record ──
$db->beginTransaction();
$stmtInsert = $db->prepare("
INSERT INTO member_cards (
card_number, member_id, dependent_id, card_type_id,
issue_date, expiry_date, status, barcode_data,
created_by, created_at, updated_at
) VALUES (
:card_number, :member_id, :dependent_id, :card_type_id,
:issue_date, :expiry_date, 'requested', :barcode_data,
:created_by, NOW(), NOW()
)
");
$stmtInsert->execute([
':card_number' => $cardNumber,
':member_id' => $memberId,
':dependent_id' => $dependentId,
':card_type_id' => $cardTypeId,
':issue_date' => $issueDate,
':expiry_date' => $expiryDate,
':barcode_data' => $barcodeData,
':created_by' => $user['id'],
]);
$cardId = $db->lastInsertId();
// ── Add to print queue automatically ──
$stmtQueue = $db->prepare("
INSERT INTO card_print_queue (card_id, priority, status, created_at)
VALUES (:card_id, 5, 'pending', NOW())
");
$stmtQueue->execute([':card_id' => $cardId]);
// ── Charge card fee if configured ──
$stmtFee = $db->prepare("
SELECT id, amount FROM fee_structures
WHERE fee_code = 'CARD_ISSUE' AND is_active = 1
LIMIT 1
");
$stmtFee->execute();
$fee = $stmtFee->fetch(PDO::FETCH_ASSOC);
$feeId = null;
if ($fee && $fee['amount'] > 0) {
$stmtCharge = $db->prepare("
INSERT INTO member_fees (
member_id, fee_structure_id, amount, fee_date,
status, description, created_by, created_at
) VALUES (
:member_id, :fee_id, :amount, CURDATE(),
'pending', :description, :created_by, NOW()
)
");
$feeDesc = 'Card issuance fee — Card #' . $cardNumber;
$stmtCharge->execute([
':member_id' => $memberId,
':fee_id' => $fee['id'],
':amount' => $fee['amount'],
':description' => $feeDesc,
':created_by' => $user['id'],
]);
$feeId = $db->lastInsertId();
}
// ── Audit trail ──
AuditTrail::log(
'card_requested',
'member_cards',
$cardId,
null,
[
'card_number' => $cardNumber,
'member_id' => $memberId,
'dependent_id' => $dependentId,
'card_type_id' => $cardTypeId,
'issue_date' => $issueDate,
'expiry_date' => $expiryDate,
'fee_charged' => $fee['amount'] ?? 0,
],
$user['id']
);
$db->commit();
echo json_encode([
'success' => true,
'message' => 'Card requested successfully.',
'card_id' => (int)$cardId,
'card_number' => $cardNumber,
'fee_id' => $feeId ? (int)$feeId : null,
'fee_amount' => $fee['amount'] ?? 0,
]);
} catch (InvalidArgumentException $e) {
if (isset($db) && $db->inTransaction()) $db->rollBack();
http_response_code(422);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
} catch (Exception $e) {
if (isset($db) && $db->inTransaction()) $db->rollBack();
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to request card.', 'detail' => $e->getMessage()]);
}
\ No newline at end of file
This diff is collapsed.
<?php
/**
* Phase 9 — Age-Out Tracking Report Data
* GET: threshold (18|21|25|all), months_ahead (default 90 days), include_overdue
*/
require_once __DIR__ . '/../../includes/config.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
requireLogin();
requirePermission('dependents.age_out_report');
header('Content-Type: application/json; charset=utf-8');
try {
$draw = intval($_GET['draw'] ?? 1);
$start = intval($_GET['start'] ?? 0);
$length = intval($_GET['length'] ?? 25);
$search = trim($_GET['search']['value'] ?? '');
$threshold = trim($_GET['threshold'] ?? 'all');
$daysAhead = intval($_GET['days_ahead'] ?? 90);
$includeOverdue = intval($_GET['include_overdue'] ?? 1);
$thresholdAges = [];
if ($threshold === 'all' || $threshold === '') {
$thresholdAges = [18, 21, 25];
} else {
$thresholdAges = [intval($threshold)];
}
$today = new DateTime();
$futureDate = (clone $today)->modify("+{$daysAhead} days");
// Build UNION query for each threshold
$unionParts = [];
$params = [];
foreach ($thresholdAges as $idx => $age) {
// Dependents who will reach $age within the window
$dobFrom = (clone $futureDate)->modify("-{$age} years")->format('Y-m-d');
$dobTo = (clone $today)->modify("-{$age} years")->format('Y-m-d');
if ($includeOverdue) {
// Include those who already passed the threshold (still active)
$dobFromOverdue = '1900-01-01';
$q = "
SELECT
d.id,
d.member_id,
d.first_name_ar,
d.second_name_ar,
d.last_name_ar,
d.date_of_birth,
d.gender,
d.is_active,
d.status,
d.is_disabled,
d.national_id,
m.membership_number,
CONCAT(m.first_name_ar, ' ', m.last_name_ar) AS member_name,
m.phone AS member_phone,
rt.name_ar AS relationship_ar,
rt.code AS rel_code,
TIMESTAMPDIFF(YEAR, d.date_of_birth, CURDATE()) AS current_age,
DATE_ADD(d.date_of_birth, INTERVAL {$age} YEAR) AS threshold_date,
DATEDIFF(DATE_ADD(d.date_of_birth, INTERVAL {$age} YEAR), CURDATE()) AS days_remaining,
{$age} AS threshold_age,
CASE
WHEN DATE_ADD(d.date_of_birth, INTERVAL {$age} YEAR) < CURDATE() THEN 'overdue'
WHEN DATE_ADD(d.date_of_birth, INTERVAL {$age} YEAR) <= DATE_ADD(CURDATE(), INTERVAL 90 DAY) THEN 'urgent'
ELSE 'approaching'
END AS urgency
FROM member_dependents d
JOIN members m ON m.id = d.member_id
JOIN relationship_types rt ON rt.id = d.relationship_type_id
WHERE rt.code IN ('son', 'daughter')
AND d.is_disabled = 0
AND d.status NOT IN ('removed')
AND d.date_of_birth <= :dob_to_{$idx}
AND TIMESTAMPDIFF(YEAR, d.date_of_birth, CURDATE()) >= " . ($age - 3) . "
AND TIMESTAMPDIFF(YEAR, d.date_of_birth, CURDATE()) <= " . ($age + 2) . "
";
$params[":dob_to_{$idx}"] = $dobTo;
// For overdue: only show active ones that already passed threshold
// For approaching: show within the window
} else {
$q = "
SELECT
d.id, d.member_id, d.first_name_ar, d.second_name_ar, d.last_name_ar,
d.date_of_birth, d.gender, d.is_active, d.status, d.is_disabled, d.national_id,
m.membership_number,
CONCAT(m.first_name_ar, ' ', m.last_name_ar) AS member_name,
m.phone AS member_phone,
rt.name_ar AS relationship_ar, rt.code AS rel_code,
TIMESTAMPDIFF(YEAR, d.date_of_birth, CURDATE()) AS current_age,
DATE_ADD(d.date_of_birth, INTERVAL {$age} YEAR) AS threshold_date,
DATEDIFF(DATE_ADD(d.date_of_birth, INTERVAL {$age} YEAR), CURDATE()) AS days_remaining,
{$age} AS threshold_age,
'approaching' AS urgency
FROM member_dependents d
JOIN members m ON m.id = d.member_id
JOIN relationship_types rt ON rt.id = d.relationship_type_id
WHERE rt.code IN ('son', 'daughter')
AND d.is_disabled = 0
AND d.status NOT IN ('removed')
AND d.date_of_birth BETWEEN :dob_from_{$idx} AND :dob_to_{$idx}
";
$params[":dob_from_{$idx}"] = $dobFrom;
$params[":dob_to_{$idx}"] = $dobTo;
}
if ($search !== '') {
$q .= " AND (
d.first_name_ar LIKE :search_{$idx}
OR d.last_name_ar LIKE :search_{$idx}
OR d.national_id LIKE :search_{$idx}
OR m.membership_number LIKE :search_{$idx}
)";
$params[":search_{$idx}"] = "%{$search}%";
}
$unionParts[] = "({$q})";
}
$fullSQL = implode(" UNION ALL ", $unionParts);
// Count
$countSQL = "SELECT COUNT(*) FROM ({$fullSQL}) AS combined";
$stmtCount = $pdo->prepare($countSQL);
$stmtCount->execute($params);
$totalFiltered = (int)$stmtCount->fetchColumn();
// Data with sort and pagination
$dataSQL = "{$fullSQL} ORDER BY days_remaining ASC, threshold_age ASC LIMIT :start, :length";
$stmtData = $pdo->prepare($dataSQL);
foreach ($params as $k => $v) {
$stmtData->bindValue($k, $v);
}
$stmtData->bindValue(':start', $start, PDO::PARAM_INT);
$stmtData->bindValue(':length', $length, PDO::PARAM_INT);
$stmtData->execute();
$rows = $stmtData->fetchAll(PDO::FETCH_ASSOC);
$data = [];
foreach ($rows as $row) {
$depName = trim("{$row['first_name_ar']} {$row['second_name_ar']} {$row['last_name_ar']}");
$daysRem = (int)$row['days_remaining'];
// Urgency badge
$urgencyBadge = match($row['urgency']) {
'overdue' => '<span class="badge bg-danger"><i class="fas fa-exclamation-triangle"></i> متأخر</span>',
'urgent' => '<span class="badge bg-warning text-dark"><i class="fas fa-exclamation-circle"></i> عاجل</span>',
'approaching' => '<span class="badge bg-info"><i class="fas fa-clock"></i> يقترب</span>',
default => '<span class="badge bg-secondary">—</span>'
};
$thresholdBadge = match((int)$row['threshold_age']) {
18 => '<span class="badge bg-info">18</span>',
21 => '<span class="badge bg-warning text-dark">21</span>',
25 => '<span class="badge bg-danger">25</span>',
default => '<span class="badge bg-secondary">' . $row['threshold_age'] . '</span>'
};
$statusBadge = $row['is_active']
? '<span class="badge bg-success">فعّال</span>'
: '<span class="badge bg-secondary">غير فعّال</span>';
$daysDisplay = $daysRem < 0
? '<span class="text-danger fw-bold">' . abs($daysRem) . ' يوم متأخر</span>'
: '<span class="fw-bold">' . $daysRem . ' يوم</span>';
$actions = '<a href="' . BASE_URL . '/pages/dependents/member-dependents.php?member_id=' . $row['member_id'] . '" class="btn btn-sm btn-outline-primary"><i class="fas fa-eye"></i></a>';
$data[] = [
'<a href="' . BASE_URL . '/pages/members/view.php?id=' . $row['member_id'] . '">' . e($row['membership_number']) . '</a>',
e($row['member_name']),
e($depName),
e($row['relationship_ar']),
e($row['date_of_birth']),
$row['current_age'] . ' سنة',
$thresholdBadge,
e($row['threshold_date']),
$daysDisplay,
$urgencyBadge,
$statusBadge,
$actions
];
}
echo json_encode([
'draw' => $draw,
'recordsTotal' => $totalFiltered,
'recordsFiltered' => $totalFiltered,
'data' => $data
], JSON_UNESCAPED_UNICODE);
} catch (Exception $ex) {
http_response_code(500);
echo json_encode([
'draw' => $draw ?? 1,
'recordsTotal' => 0,
'recordsFiltered' => 0,
'data' => [],
'error' => $ex->getMessage()
], JSON_UNESCAPED_UNICODE);
}
\ No newline at end of file
<?php
/**
* Phase 9 — Deactivate Dependent
* POST: dependent_id, reason
*/
require_once __DIR__ . '/../../includes/config.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
require_once __DIR__ . '/../../includes/audit.php';
requireLogin();
requirePermission('dependents.deactivate');
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
try {
verifyCsrf($_POST['csrf_token'] ?? '');
$dependentId = intval($_POST['dependent_id'] ?? 0);
$reason = trim($_POST['reason'] ?? '');
if ($dependentId <= 0) {
throw new InvalidArgumentException('معرّف المرافق مطلوب');
}
$stmtDep = $pdo->prepare("SELECT * FROM member_dependents WHERE id = :id");
$stmtDep->execute([':id' => $dependentId]);
$dep = $stmtDep->fetch(PDO::FETCH_ASSOC);
if (!$dep) {
echo json_encode(['success' => false, 'message' => 'المرافق غير موجود'], JSON_UNESCAPED_UNICODE);
exit;
}
if (!$dep['is_active']) {
echo json_encode(['success' => false, 'message' => 'المرافق غير مفعّل بالفعل'], JSON_UNESCAPED_UNICODE);
exit;
}
$pdo->beginTransaction();
// Deactivate the dependent
$stmtDeact = $pdo->prepare("
UPDATE member_dependents SET
is_active = 0,
status = 'suspended',
notes = CONCAT(IFNULL(notes, ''), '\n[إلغاء تفعيل: ', NOW(), '] ', :reason),
updated_by = :uid,
updated_at = NOW()
WHERE id = :id
");
$stmtDeact->execute([
':reason' => $reason,
':uid' => currentUserId(),
':id' => $dependentId
]);
// Cancel active activation record
$stmtCancelAct = $pdo->prepare("
UPDATE dependent_activations SET status = 'cancelled'
WHERE dependent_id = :dep_id AND status = 'active'
");
$stmtCancelAct->execute([':dep_id' => $dependentId]);
AuditTrail::log($pdo, 'dependent_deactivated', 'member_dependents', $dependentId, [
'was_active' => true,
'old_status' => $dep['status']
], [
'reason' => $reason,
'member_id' => $dep['member_id']
]);
$pdo->commit();
echo json_encode([
'success' => true,
'message' => 'تم إلغاء تفعيل المرافق بنجاح'
], JSON_UNESCAPED_UNICODE);
} catch (Exception $ex) {
if ($pdo->inTransaction()) $pdo->rollBack();
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'خطأ: ' . $ex->getMessage()], JSON_UNESCAPED_UNICODE);
}
\ No newline at end of file
<?php
/**
* Phase 9 — All Dependents List (DataTables Server-Side)
* GET: draw, start, length, search, order, filters
*/
require_once __DIR__ . '/../../includes/config.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
requireLogin();
requirePermission('dependents.view');
header('Content-Type: application/json; charset=utf-8');
try {
$draw = intval($_GET['draw'] ?? 1);
$start = intval($_GET['start'] ?? 0);
$length = intval($_GET['length'] ?? 25);
$search = trim($_GET['search']['value'] ?? '');
$orderColIdx = intval($_GET['order'][0]['column'] ?? 0);
$orderDir = ($_GET['order'][0]['dir'] ?? 'asc') === 'desc' ? 'DESC' : 'ASC';
// Filters
$filterStatus = trim($_GET['filter_status'] ?? '');
$filterRelationship = intval($_GET['filter_relationship'] ?? 0);
$filterGender = trim($_GET['filter_gender'] ?? '');
$filterActive = trim($_GET['filter_active'] ?? '');
$columns = [
'd.id',
'd.first_name_ar',
'm.membership_number',
'rt.name_ar',
'd.date_of_birth',
'd.gender',
'd.national_id',
'd.status',
'd.is_active',
'd.created_at'
];
$orderColumn = $columns[$orderColIdx] ?? 'd.id';
$where = [];
$params = [];
if ($search !== '') {
$where[] = "(
d.first_name_ar LIKE :search
OR d.last_name_ar LIKE :search
OR d.first_name_en LIKE :search
OR d.last_name_en LIKE :search
OR d.national_id LIKE :search
OR m.membership_number LIKE :search
OR CONCAT(m.first_name_ar, ' ', m.last_name_ar) LIKE :search
)";
$params[':search'] = "%{$search}%";
}
if ($filterStatus !== '') {
$where[] = "d.status = :fstatus";
$params[':fstatus'] = $filterStatus;
}
if ($filterRelationship > 0) {
$where[] = "d.relationship_type_id = :frel";
$params[':frel'] = $filterRelationship;
}
if ($filterGender !== '') {
$where[] = "d.gender = :fgender";
$params[':fgender'] = $filterGender;
}
if ($filterActive !== '') {
$where[] = "d.is_active = :factive";
$params[':factive'] = intval($filterActive);
}
$whereSQL = count($where) > 0 ? 'WHERE ' . implode(' AND ', $where) : '';
// Total records (unfiltered)
$stmtTotal = $pdo->query("SELECT COUNT(*) FROM member_dependents");
$totalRecords = $stmtTotal->fetchColumn();
// Filtered count
$countSQL = "
SELECT COUNT(*)
FROM member_dependents d
JOIN members m ON m.id = d.member_id
JOIN relationship_types rt ON rt.id = d.relationship_type_id
{$whereSQL}
";
$stmtFiltered = $pdo->prepare($countSQL);
$stmtFiltered->execute($params);
$filteredRecords = $stmtFiltered->fetchColumn();
// Data query
$dataSQL = "
SELECT
d.id,
d.member_id,
d.first_name_ar,
d.second_name_ar,
d.third_name_ar,
d.last_name_ar,
d.first_name_en,
d.last_name_en,
d.date_of_birth,
d.gender,
d.national_id,
d.phone,
d.photo,
d.is_active,
d.status,
d.is_disabled,
d.created_at,
m.membership_number,
CONCAT(m.first_name_ar, ' ', m.last_name_ar) AS member_name_ar,
rt.name_ar AS relationship_ar,
rt.name_en AS relationship_en,
rt.code AS relationship_code,
TIMESTAMPDIFF(YEAR, d.date_of_birth, CURDATE()) AS age
FROM member_dependents d
JOIN members m ON m.id = d.member_id
JOIN relationship_types rt ON rt.id = d.relationship_type_id
{$whereSQL}
ORDER BY {$orderColumn} {$orderDir}
LIMIT :start, :length
";
$stmtData = $pdo->prepare($dataSQL);
foreach ($params as $k => $v) {
$stmtData->bindValue($k, $v);
}
$stmtData->bindValue(':start', $start, PDO::PARAM_INT);
$stmtData->bindValue(':length', $length, PDO::PARAM_INT);
$stmtData->execute();
$rows = $stmtData->fetchAll(PDO::FETCH_ASSOC);
$data = [];
foreach ($rows as $row) {
$fullNameAr = trim("{$row['first_name_ar']} {$row['second_name_ar']} {$row['third_name_ar']} {$row['last_name_ar']}");
$fullNameEn = trim("{$row['first_name_en']} {$row['last_name_en']}");
$statusBadge = match($row['status']) {
'active' => '<span class="badge bg-success">فعّال</span>',
'pending' => '<span class="badge bg-warning text-dark">معلّق</span>',
'suspended' => '<span class="badge bg-danger">موقوف</span>',
'aged_out' => '<span class="badge bg-secondary">تجاوز السن</span>',
'removed' => '<span class="badge bg-dark">محذوف</span>',
default => '<span class="badge bg-light text-dark">' . e($row['status']) . '</span>'
};
$genderIcon = $row['gender'] === 'male'
? '<i class="fas fa-mars text-primary"></i> ذكر'
: '<i class="fas fa-venus text-danger"></i> أنثى';
$ageDisplay = $row['age'] . ' سنة';
$ageClass = '';
if ($row['age'] >= 25) $ageClass = 'text-danger fw-bold';
elseif ($row['age'] >= 21) $ageClass = 'text-warning fw-bold';
elseif ($row['age'] >= 18) $ageClass = 'text-info';
$actions = '<div class="btn-group btn-group-sm">';
if (hasPermission('dependents.edit')) {
$actions .= '<a href="' . BASE_URL . '/pages/dependents/edit.php?id=' . $row['id'] . '" class="btn btn-outline-primary" title="تعديل"><i class="fas fa-edit"></i></a>';
}
if (!$row['is_active'] && hasPermission('dependents.activate') && $row['status'] !== 'removed') {
$actions .= '<a href="' . BASE_URL . '/pages/dependents/activate.php?id=' . $row['id'] . '" class="btn btn-outline-success" title="تفعيل"><i class="fas fa-check-circle"></i></a>';
}
if ($row['is_active'] && hasPermission('dependents.deactivate')) {
$actions .= '<button class="btn btn-outline-danger btn-deactivate" data-id="' . $row['id'] . '" title="إلغاء التفعيل"><i class="fas fa-times-circle"></i></button>';
}
$actions .= '<a href="' . BASE_URL . '/pages/dependents/member-dependents.php?member_id=' . $row['member_id'] . '" class="btn btn-outline-info" title="عرض العائلة"><i class="fas fa-users"></i></a>';
$actions .= '</div>';
$data[] = [
$row['id'],
$fullNameAr . ($fullNameEn ? '<br><small class="text-muted">' . e($fullNameEn) . '</small>' : ''),
'<a href="' . BASE_URL . '/pages/members/view.php?id=' . $row['member_id'] . '">' . e($row['membership_number']) . '</a><br><small>' . e($row['member_name_ar']) . '</small>',
e($row['relationship_ar']),
e($row['date_of_birth']) . '<br><small class="' . $ageClass . '">' . $ageDisplay . '</small>',
$genderIcon,
e($row['national_id'] ?: '—'),
$statusBadge,
$row['is_active'] ? '<i class="fas fa-check-circle text-success"></i>' : '<i class="fas fa-times-circle text-muted"></i>',
$actions
];
}
echo json_encode([
'draw' => $draw,
'recordsTotal' => (int)$totalRecords,
'recordsFiltered' => (int)$filteredRecords,
'data' => $data
], JSON_UNESCAPED_UNICODE);
} catch (Exception $ex) {
http_response_code(500);
echo json_encode([
'draw' => $draw ?? 1,
'recordsTotal' => 0,
'recordsFiltered' => 0,
'data' => [],
'error' => $ex->getMessage()
], JSON_UNESCAPED_UNICODE);
}
\ No newline at end of file
<?php
/**
* Phase 9 — Dependents for a Specific Member (DataTables Server-Side)
* GET: member_id + standard DataTables params
*/
require_once __DIR__ . '/../../includes/config.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
requireLogin();
requirePermission('dependents.view');
header('Content-Type: application/json; charset=utf-8');
try {
$memberId = intval($_GET['member_id'] ?? 0);
if ($memberId <= 0) {
throw new InvalidArgumentException('معرّف العضو مطلوب');
}
$draw = intval($_GET['draw'] ?? 1);
$start = intval($_GET['start'] ?? 0);
$length = intval($_GET['length'] ?? 50);
$search = trim($_GET['search']['value'] ?? '');
$where = ["d.member_id = :member_id"];
$params = [':member_id' => $memberId];
if ($search !== '') {
$where[] = "(
d.first_name_ar LIKE :search
OR d.last_name_ar LIKE :search
OR d.first_name_en LIKE :search
OR d.national_id LIKE :search
)";
$params[':search'] = "%{$search}%";
}
$whereSQL = 'WHERE ' . implode(' AND ', $where);
// Counts
$stmtTotal = $pdo->prepare("SELECT COUNT(*) FROM member_dependents WHERE member_id = :mid");
$stmtTotal->execute([':mid' => $memberId]);
$totalRecords = $stmtTotal->fetchColumn();
$countSQL = "
SELECT COUNT(*)
FROM member_dependents d
JOIN relationship_types rt ON rt.id = d.relationship_type_id
{$whereSQL}
";
$stmtFiltered = $pdo->prepare($countSQL);
$stmtFiltered->execute($params);
$filteredRecords = $stmtFiltered->fetchColumn();
// Data
$dataSQL = "
SELECT
d.*,
rt.name_ar AS relationship_ar,
rt.name_en AS relationship_en,
rt.code AS relationship_code,
TIMESTAMPDIFF(YEAR, d.date_of_birth, CURDATE()) AS age,
da.activation_date AS last_activation_date,
da.expiry_date AS activation_expiry
FROM member_dependents d
JOIN relationship_types rt ON rt.id = d.relationship_type_id
LEFT JOIN dependent_activations da ON da.dependent_id = d.id AND da.status = 'active'
{$whereSQL}
ORDER BY d.is_active DESC, rt.id ASC, d.date_of_birth ASC
LIMIT :start, :length
";
$stmtData = $pdo->prepare($dataSQL);
foreach ($params as $k => $v) {
$stmtData->bindValue($k, $v);
}
$stmtData->bindValue(':start', $start, PDO::PARAM_INT);
$stmtData->bindValue(':length', $length, PDO::PARAM_INT);
$stmtData->execute();
$rows = $stmtData->fetchAll(PDO::FETCH_ASSOC);
// Get config values for age alerts
$maxAge = intval(getSysConfig($pdo, 'dependent_max_age', 21));
$data = [];
foreach ($rows as $row) {
$fullNameAr = trim("{$row['first_name_ar']} {$row['second_name_ar']} {$row['third_name_ar']} {$row['last_name_ar']}");
$age = (int)$row['age'];
$ageClass = '';
$ageWarning = '';
if (!$row['is_disabled']) {
if ($age >= 25) {
$ageClass = 'text-danger fw-bold';
$ageWarning = '<br><small class="text-danger"><i class="fas fa-exclamation-triangle"></i> تجاوز الحد النهائي</small>';
} elseif ($age >= $maxAge) {
$ageClass = 'text-warning fw-bold';
$ageWarning = '<br><small class="text-warning"><i class="fas fa-exclamation-circle"></i> يتطلب تحويل</small>';
} elseif ($age >= 18) {
$ageClass = 'text-info';
$ageWarning = '<br><small class="text-info"><i class="fas fa-info-circle"></i> رسوم إضافية</small>';
}
} else {
$ageWarning = '<br><small class="text-success"><i class="fas fa-wheelchair"></i> معفي من حد السن</small>';
}
$statusBadge = match($row['status']) {
'active' => '<span class="badge bg-success">فعّال</span>',
'pending' => '<span class="badge bg-warning text-dark">معلّق</span>',
'suspended' => '<span class="badge bg-danger">موقوف</span>',
'aged_out' => '<span class="badge bg-secondary">تجاوز السن</span>',
'removed' => '<span class="badge bg-dark">محذوف</span>',
default => '<span class="badge bg-light text-dark">' . e($row['status']) . '</span>'
};
$photoHtml = $row['photo']
? '<img src="' . BASE_URL . '/' . e($row['photo']) . '" class="rounded-circle" width="40" height="40" style="object-fit:cover;">'
: '<span class="avatar-placeholder rounded-circle d-inline-flex align-items-center justify-content-center bg-light" style="width:40px;height:40px;"><i class="fas fa-user text-muted"></i></span>';
$actions = '<div class="btn-group btn-group-sm">';
if (hasPermission('dependents.edit')) {
$actions .= '<a href="' . BASE_URL . '/pages/dependents/edit.php?id=' . $row['id'] . '" class="btn btn-outline-primary" title="تعديل"><i class="fas fa-edit"></i></a>';
}
if (!$row['is_active'] && hasPermission('dependents.activate') && !in_array($row['status'], ['removed', 'aged_out'])) {
$actions .= '<a href="' . BASE_URL . '/pages/dependents/activate.php?id=' . $row['id'] . '" class="btn btn-outline-success" title="تفعيل"><i class="fas fa-check-circle"></i></a>';
}
if ($row['is_active'] && hasPermission('dependents.deactivate')) {
$actions .= '<button class="btn btn-outline-danger btn-deactivate" data-id="' . $row['id'] . '" title="إلغاء التفعيل"><i class="fas fa-times-circle"></i></button>';
}
$actions .= '</div>';
$data[] = [
$photoHtml,
$fullNameAr,
e($row['relationship_ar']),
e($row['date_of_birth']) . '<br><span class="' . $ageClass . '">' . $age . ' سنة</span>' . $ageWarning,
$row['gender'] === 'male' ? '<i class="fas fa-mars text-primary"></i>' : '<i class="fas fa-venus text-danger"></i>',
e($row['national_id'] ?: '—'),
$statusBadge,
$row['activation_expiry'] ? e($row['activation_expiry']) : '—',
$actions
];
}
echo json_encode([
'draw' => $draw,
'recordsTotal' => (int)$totalRecords,
'recordsFiltered' => (int)$filteredRecords,
'data' => $data
], JSON_UNESCAPED_UNICODE);
} catch (Exception $ex) {
http_response_code(500);
echo json_encode([
'draw' => $draw ?? 1,
'recordsTotal' => 0,
'recordsFiltered' => 0,
'data' => [],
'error' => $ex->getMessage()
], JSON_UNESCAPED_UNICODE);
}
/**
* Get sys_config value
*/
function getSysConfig(PDO $pdo, string $key, string $default = ''): string
{
static $cache = [];
if (!isset($cache[$key])) {
$stmt = $pdo->prepare("SELECT config_value FROM sys_config WHERE config_key = :k AND is_active = 1 LIMIT 1");
$stmt->execute([':k' => $key]);
$cache[$key] = $stmt->fetchColumn() ?: $default;
}
return $cache[$key];
}
\ No newline at end of file
This diff is collapsed.
<?php
/**
* Phase 9 — Update Dependent
* POST: id, first_name_ar, last_name_ar, etc.
*/
require_once __DIR__ . '/../../includes/config.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
require_once __DIR__ . '/../../includes/audit.php';
requireLogin();
requirePermission('dependents.edit');
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
try {
verifyCsrf($_POST['csrf_token'] ?? '');
$id = intval($_POST['id'] ?? 0);
if ($id <= 0) {
throw new InvalidArgumentException('معرّف المرافق مطلوب');
}
// Fetch existing
$stmtOld = $pdo->prepare("SELECT * FROM member_dependents WHERE id = :id");
$stmtOld->execute([':id' => $id]);
$old = $stmtOld->fetch(PDO::FETCH_ASSOC);
if (!$old) {
echo json_encode(['success' => false, 'message' => 'المرافق غير موجود'], JSON_UNESCAPED_UNICODE);
exit;
}
if ($old['status'] === 'removed') {
echo json_encode(['success' => false, 'message' => 'لا يمكن تعديل مرافق محذوف'], JSON_UNESCAPED_UNICODE);
exit;
}
// Collect fields
$firstNameAr = trim($_POST['first_name_ar'] ?? $old['first_name_ar']);
$secondNameAr = trim($_POST['second_name_ar'] ?? $old['second_name_ar']);
$thirdNameAr = trim($_POST['third_name_ar'] ?? $old['third_name_ar']);
$lastNameAr = trim($_POST['last_name_ar'] ?? $old['last_name_ar']);
$firstNameEn = trim($_POST['first_name_en'] ?? $old['first_name_en']);
$lastNameEn = trim($_POST['last_name_en'] ?? $old['last_name_en']);
$relationshipTypeId = intval($_POST['relationship_type_id'] ?? $old['relationship_type_id']);
$dateOfBirth = trim($_POST['date_of_birth'] ?? $old['date_of_birth']);
$gender = trim($_POST['gender'] ?? $old['gender']);
$nationalId = trim($_POST['national_id'] ?? $old['national_id']);
$phone = trim($_POST['phone'] ?? $old['phone']);
$email = trim($_POST['email'] ?? $old['email']);
$isDisabled = intval($_POST['is_disabled'] ?? $old['is_disabled']);
$disabilityType = trim($_POST['disability_type'] ?? $old['disability_type']);
$disabilityPct = floatval($_POST['disability_percentage'] ?? $old['disability_percentage']);
$medicalCertExpiry = trim($_POST['medical_cert_expiry'] ?? $old['medical_cert_expiry']);
$notes = trim($_POST['notes'] ?? $old['notes']);
// ── Validation ──
$errors = [];
if ($firstNameAr === '') $errors[] = 'الاسم الأول بالعربية مطلوب';
if ($lastNameAr === '') $errors[] = 'اسم العائلة بالعربية مطلوب';
if ($relationshipTypeId <= 0) $errors[] = 'صلة القرابة مطلوبة';
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateOfBirth)) $errors[] = 'تاريخ الميلاد غير صالح';
if (!in_array($gender, ['male', 'female'])) $errors[] = 'الجنس غير صالح';
// National ID duplicate check (exclude current)
if ($nationalId !== '' && $nationalId !== $old['national_id']) {
if (!preg_match('/^\d{10,14}$/', $nationalId)) {
$errors[] = 'الرقم القومي غير صالح';
} else {
$stmtNid = $pdo->prepare("SELECT id FROM member_dependents WHERE national_id = :nid AND id != :id AND status != 'removed'");
$stmtNid->execute([':nid' => $nationalId, ':id' => $id]);
if ($stmtNid->fetch()) $errors[] = 'الرقم القومي مسجّل لمرافق آخر';
$stmtNidM = $pdo->prepare("SELECT id FROM members WHERE national_id = :nid LIMIT 1");
$stmtNidM->execute([':nid' => $nationalId]);
if ($stmtNidM->fetch()) $errors[] = 'الرقم القومي مسجّل كعضو أساسي';
}
}
if ($isDisabled && $disabilityType === '') {
$errors[] = 'نوع الإعاقة مطلوب';
}
if (!empty($errors)) {
echo json_encode(['success' => false, 'message' => implode('<br>', $errors)], JSON_UNESCAPED_UNICODE);
exit;
}
// ── Update ──
$pdo->beginTransaction();
$sql = "
UPDATE member_dependents SET
first_name_ar = :fn_ar,
second_name_ar = :sn_ar,
third_name_ar = :tn_ar,
last_name_ar = :ln_ar,
first_name_en = :fn_en,
last_name_en = :ln_en,
relationship_type_id = :rel_id,
date_of_birth = :dob,
gender = :gender,
national_id = :nid,
phone = :phone,
email = :email,
is_disabled = :is_disabled,
disability_type = :dis_type,
disability_percentage = :dis_pct,
medical_cert_expiry = :med_exp,
notes = :notes,
updated_by = :updated_by,
updated_at = NOW()
WHERE id = :id
";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':fn_ar' => $firstNameAr,
':sn_ar' => $secondNameAr ?: null,
':tn_ar' => $thirdNameAr ?: null,
':ln_ar' => $lastNameAr,
':fn_en' => $firstNameEn ?: null,
':ln_en' => $lastNameEn ?: null,
':rel_id' => $relationshipTypeId,
':dob' => $dateOfBirth,
':gender' => $gender,
':nid' => $nationalId ?: null,
':phone' => $phone ?: null,
':email' => $email ?: null,
':is_disabled' => $isDisabled,
':dis_type' => $isDisabled ? $disabilityType : null,
':dis_pct' => $isDisabled ? $disabilityPct : null,
':med_exp' => ($isDisabled && $medicalCertExpiry) ? $medicalCertExpiry : null,
':notes' => $notes ?: null,
':updated_by' => currentUserId(),
':id' => $id
]);
AuditTrail::log($pdo, 'dependent_updated', 'member_dependents', $id, $old, [
'first_name_ar' => $firstNameAr,
'last_name_ar' => $lastNameAr,
'dob' => $dateOfBirth,
'national_id' => $nationalId
]);
$pdo->commit();
echo json_encode([
'success' => true,
'message' => 'تم تحديث بيانات المرافق بنجاح',
'data' => ['id' => $id, 'member_id' => $old['member_id']]
], JSON_UNESCAPED_UNICODE);
} catch (Exception $ex) {
if ($pdo->inTransaction()) $pdo->rollBack();
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'خطأ في النظام: ' . $ex->getMessage()], JSON_UNESCAPED_UNICODE);
}
\ No newline at end of file
<?php
/**
* Phase 9 — Upload Dependent Photo
* POST: dependent_id, photo (file)
*/
require_once __DIR__ . '/../../includes/config.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
require_once __DIR__ . '/../../includes/audit.php';
requireLogin();
requirePermission('dependents.edit');
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
try {
verifyCsrf($_POST['csrf_token'] ?? '');
$dependentId = intval($_POST['dependent_id'] ?? 0);
if ($dependentId <= 0) {
throw new InvalidArgumentException('معرّف المرافق مطلوب');
}
// Fetch dependent
$stmtDep = $pdo->prepare("SELECT id, member_id, photo FROM member_dependents WHERE id = :id");
$stmtDep->execute([':id' => $dependentId]);
$dep = $stmtDep->fetch(PDO::FETCH_ASSOC);
if (!$dep) {
echo json_encode(['success' => false, 'message' => 'المرافق غير موجود'], JSON_UNESCAPED_UNICODE);
exit;
}
if (!isset($_FILES['photo']) || $_FILES['photo']['error'] !== UPLOAD_ERR_OK) {
$errorMessages = [
UPLOAD_ERR_INI_SIZE => 'حجم الملف يتجاوز الحد المسموح',
UPLOAD_ERR_FORM_SIZE => 'حجم الملف يتجاوز الحد المسموح',
UPLOAD_ERR_PARTIAL => 'تم رفع الملف جزئياً فقط',
UPLOAD_ERR_NO_FILE => 'لم يتم اختيار ملف',
UPLOAD_ERR_NO_TMP_DIR => 'مجلد مؤقت غير موجود',
UPLOAD_ERR_CANT_WRITE => 'فشل كتابة الملف',
];
$errCode = $_FILES['photo']['error'] ?? UPLOAD_ERR_NO_FILE;
$errMsg = $errorMessages[$errCode] ?? 'خطأ غير معروف في رفع الملف';
echo json_encode(['success' => false, 'message' => $errMsg], JSON_UNESCAPED_UNICODE);
exit;
}
$file = $_FILES['photo'];
$allowedTypes = ['image/jpeg', 'image/png', 'image/jpg'];
$maxSize = 5 * 1024 * 1024; // 5MB
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
if (!in_array($mimeType, $allowedTypes)) {
echo json_encode(['success' => false, 'message' => 'نوع الملف غير مدعوم. يُسمح فقط بـ JPG و PNG'], JSON_UNESCAPED_UNICODE);
exit;
}
if ($file['size'] > $maxSize) {
echo json_encode(['success' => false, 'message' => 'حجم الملف يتجاوز 5 ميجابايت'], JSON_UNESCAPED_UNICODE);
exit;
}
// Create directory
$uploadDir = UPLOAD_PATH . '/dependents/' . $dep['member_id'];
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
// Generate filename
$ext = match($mimeType) {
'image/png' => 'png',
default => 'jpg'
};
$filename = "dep_{$dependentId}_" . time() . ".{$ext}";
$filepath = $uploadDir . '/' . $filename;
$relativePath = "uploads/dependents/{$dep['member_id']}/{$filename}";
// Move file
if (!move_uploaded_file($file['tmp_name'], $filepath)) {
throw new RuntimeException('فشل في حفظ الملف');
}
// Resize if needed (max 800x800)
resizeImage($filepath, 800, 800);
// Delete old photo
$oldPhoto = $dep['photo'];
if ($oldPhoto && file_exists(BASE_PATH . '/' . $oldPhoto)) {
unlink(BASE_PATH . '/' . $oldPhoto);
}
// Update database
$stmtUpdate = $pdo->prepare("
UPDATE member_dependents SET
photo = :photo,
updated_by = :uid,
updated_at = NOW()
WHERE id = :id
");
$stmtUpdate->execute([
':photo' => $relativePath,
':uid' => currentUserId(),
':id' => $dependentId
]);
AuditTrail::log($pdo, 'dependent_photo_uploaded', 'member_dependents', $dependentId, null, [
'old_photo' => $oldPhoto,
'new_photo' => $relativePath
]);
echo json_encode([
'success' => true,
'message' => 'تم رفع الصورة بنجاح',
'data' => ['photo_url' => BASE_URL . '/' . $relativePath]
], JSON_UNESCAPED_UNICODE);
} catch (Exception $ex) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'خطأ: ' . $ex->getMessage()], JSON_UNESCAPED_UNICODE);
}
/**
* Resize image proportionally if it exceeds max dimensions
*/
function resizeImage(string $filepath, int $maxW, int $maxH): void
{
$info = getimagesize($filepath);
if (!$info) return;
[$origW, $origH, $type] = $info;
if ($origW <= $maxW && $origH <= $maxH) return;
$ratio = min($maxW / $origW, $maxH / $origH);
$newW = (int)round($origW * $ratio);
$newH = (int)round($origH * $ratio);
$src = match($type) {
IMAGETYPE_JPEG => imagecreatefromjpeg($filepath),
IMAGETYPE_PNG => imagecreatefrompng($filepath),
default => null
};
if (!$src) return;
$dst = imagecreatetruecolor($newW, $newH);
if ($type === IMAGETYPE_PNG) {
imagealphablending($dst, false);
imagesavealpha($dst, true);
}
imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
match($type) {
IMAGETYPE_JPEG => imagejpeg($dst, $filepath, 85),
IMAGETYPE_PNG => imagepng($dst, $filepath, 8),
default => null
};
imagedestroy($src);
imagedestroy($dst);
}
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/db.php';
require_once __DIR__ . '/../../includes/auth.php';
header('Content-Type: application/json');
requireLogin();
if (!hasPermission('facilities.view')) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Access denied']);
exit;
}
$facilityId = (int)($_GET['facility_id'] ?? 0);
$bookingDate = $_GET['date'] ?? '';
$startTime = $_GET['start_time'] ?? '';
$endTime = $_GET['end_time'] ?? '';
$excludeId = (int)($_GET['exclude_id'] ?? 0);
if ($facilityId <= 0 || empty($bookingDate)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Facility ID and date are required']);
exit;
}
try {
// Get all confirmed bookings for this facility on this date
$sql = "SELECT id, booked_for, start_time, end_time, status
FROM facility_bookings
WHERE facility_id = :facility_id
AND booking_date = :booking_date
AND status = 'confirmed'";
$params = [':facility_id' => $facilityId, ':booking_date' => $bookingDate];
if ($excludeId > 0) {
$sql .= " AND id != :exclude_id";
$params[':exclude_id'] = $excludeId;
}
$sql .= " ORDER BY start_time ASC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$bookings = $stmt->fetchAll(PDO::FETCH_ASSOC);
// If specific time range requested, check for conflicts
$available = true;
$conflicts = [];
if (!empty($startTime) && !empty($endTime)) {
foreach ($bookings as $booking) {
if ($startTime < $booking['end_time'] && $endTime > $booking['start_time']) {
$available = false;
$conflicts[] = $booking;
}
}
}
// Build available slots (simple gap analysis from 06:00 to 23:00)
$dayStart = '06:00:00';
$dayEnd = '23:00:00';
$availableSlots = [];
$cursor = $dayStart;
foreach ($bookings as $booking) {
if ($cursor < $booking['start_time']) {
$availableSlots[] = [
'start_time' => substr($cursor, 0, 5),
'end_time' => substr($booking['start_time'], 0, 5),
];
}
if ($booking['end_time'] > $cursor) {
$cursor = $booking['end_time'];
}
}
if ($cursor < $dayEnd) {
$availableSlots[] = [
'start_time' => substr($cursor, 0, 5),
'end_time' => substr($dayEnd, 0, 5),
];
}
echo json_encode([
'success' => true,
'available' => $available,
'conflicts' => $conflicts,
'bookings' => $bookings,
'available_slots' => $availableSlots,
]);
} catch (Exception $e) {
error_log("Availability check error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Failed to check availability']);
}
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/db.php';
require_once __DIR__ . '/../../includes/auth.php';
header('Content-Type: application/json');
requireLogin();
if (!hasPermission('facilities.book')) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Access denied']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) $input = $_POST;
if (!validateCsrfToken($input['csrf_token'] ?? '')) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Invalid CSRF token']);
exit;
}
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Invalid booking ID']);
exit;
}
try {
$oldStmt = $pdo->prepare("SELECT * FROM facility_bookings WHERE id = :id");
$oldStmt->execute([':id' => $id]);
$oldData = $oldStmt->fetch(PDO::FETCH_ASSOC);
if (!$oldData) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Booking not found']);
exit;
}
if ($oldData['status'] === 'cancelled') {
http_response_code(422);
echo json_encode(['success' => false, 'message' => 'Booking is already cancelled']);
exit;
}
$stmt = $pdo->prepare("UPDATE facility_bookings SET status = 'cancelled' WHERE id = :id");
$stmt->execute([':id' => $id]);
logAudit($pdo, $_SESSION['user_id'], 'UPDATE', 'facility_bookings', $id, $oldData, ['status' => 'cancelled']);
echo json_encode(['success' => true, 'message' => 'Booking cancelled successfully']);
} catch (Exception $e) {
error_log("Booking cancel error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Failed to cancel booking']);
}
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/db.php';
require_once __DIR__ . '/../../includes/auth.php';
header('Content-Type: application/json');
requireLogin();
if (!hasPermission('facilities.book')) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Access denied']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) $input = $_POST;
if (!validateCsrfToken($input['csrf_token'] ?? '')) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Invalid CSRF token']);
exit;
}
$errors = [];
if (empty($input['facility_id'])) $errors[] = 'Facility is required';
if (empty($input['booked_for'])) $errors[] = 'Booking purpose is required';
if (empty($input['booking_date'])) $errors[] = 'Booking date is required';
if (empty($input['start_time'])) $errors[] = 'Start time is required';
if (empty($input['end_time'])) $errors[] = 'End time is required';
if (!empty($input['start_time']) && !empty($input['end_time']) && $input['start_time'] >= $input['end_time']) {
$errors[] = 'End time must be after start time';
}
if (!empty($input['booking_date']) && $input['booking_date'] < date('Y-m-d')) {
$errors[] = 'Cannot book in the past';
}
if (!empty($errors)) {
http_response_code(422);
echo json_encode(['success' => false, 'message' => implode(', ', $errors), 'errors' => $errors]);
exit;
}
try {
$facilityId = (int)$input['facility_id'];
$bookingDate = $input['booking_date'];
$startTime = $input['start_time'];
$endTime = $input['end_time'];
// Check facility exists and is active
$facStmt = $pdo->prepare("SELECT * FROM facilities WHERE id = :id AND is_active = 1");
$facStmt->execute([':id' => $facilityId]);
if (!$facStmt->fetch()) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Facility not found or inactive']);
exit;
}
// Conflict detection — no overlapping confirmed bookings
$conflictSql = "SELECT id, booked_for, start_time, end_time
FROM facility_bookings
WHERE facility_id = :facility_id
AND booking_date = :booking_date
AND status = 'confirmed'
AND start_time < :end_time
AND end_time > :start_time";
$conflictStmt = $pdo->prepare($conflictSql);
$conflictStmt->execute([
':facility_id' => $facilityId,
':booking_date' => $bookingDate,
':start_time' => $startTime,
':end_time' => $endTime,
]);
$conflicts = $conflictStmt->fetchAll(PDO::FETCH_ASSOC);
if (count($conflicts) > 0) {
$conflictInfo = array_map(function ($c) {
return $c['booked_for'] . ' (' . substr($c['start_time'], 0, 5) . '-' . substr($c['end_time'], 0, 5) . ')';
}, $conflicts);
http_response_code(409);
echo json_encode([
'success' => false,
'message' => 'Time slot conflicts with existing booking(s): ' . implode(', ', $conflictInfo),
'conflicts' => $conflicts
]);
exit;
}
$sql = "INSERT INTO facility_bookings (facility_id, booked_for, booking_date, start_time, end_time, booked_by, status, notes)
VALUES (:facility_id, :booked_for, :booking_date, :start_time, :end_time, :booked_by, 'confirmed', :notes)";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':facility_id' => $facilityId,
':booked_for' => trim($input['booked_for']),
':booking_date' => $bookingDate,
':start_time' => $startTime,
':end_time' => $endTime,
':booked_by' => $_SESSION['user_id'],
':notes' => trim($input['notes'] ?? ''),
]);
$bookingId = $pdo->lastInsertId();
logAudit($pdo, $_SESSION['user_id'], 'CREATE', 'facility_bookings', $bookingId, null, $input);
echo json_encode(['success' => true, 'message' => 'Booking created successfully', 'id' => $bookingId]);
} catch (Exception $e) {
error_log("Booking store error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Failed to create booking']);
}
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/db.php';
require_once __DIR__ . '/../../includes/auth.php';
header('Content-Type: application/json');
requireLogin();
if (!hasPermission('facilities.view')) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Access denied']);
exit;
}
try {
$facilityId = (int)($_GET['facility_id'] ?? 0);
$startDate = $_GET['start'] ?? date('Y-m-01');
$endDate = $_GET['end'] ?? date('Y-m-t');
$where = ["fb.booking_date BETWEEN :start_date AND :end_date", "fb.status = 'confirmed'"];
$params = [':start_date' => $startDate, ':end_date' => $endDate];
if ($facilityId > 0) {
$where[] = 'fb.facility_id = :facility_id';
$params[':facility_id'] = $facilityId;
}
$whereClause = implode(' AND ', $where);
$sql = "SELECT fb.*, f.name_en AS facility_name, f.name_ar AS facility_name_ar
FROM facility_bookings fb
JOIN facilities f ON fb.facility_id = f.id
WHERE {$whereClause}
ORDER BY fb.booking_date, fb.start_time";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$bookings = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Format for FullCalendar
$events = array_map(function ($b) {
return [
'id' => $b['id'],
'title' => $b['booked_for'] . ' - ' . $b['facility_name'],
'start' => $b['booking_date'] . 'T' . $b['start_time'],
'end' => $b['booking_date'] . 'T' . $b['end_time'],
'facility_id' => $b['facility_id'],
'facility_name' => $b['facility_name'],
'booked_for' => $b['booked_for'],
'status' => $b['status'],
'backgroundColor' => $b['status'] === 'cancelled' ? '#dc3545' : '#0d6efd',
'borderColor' => $b['status'] === 'cancelled' ? '#dc3545' : '#0d6efd',
'extendedProps' => [
'facility_id' => $b['facility_id'],
'facility_name' => $b['facility_name'],
'booked_for' => $b['booked_for'],
'booked_by' => $b['booked_by'],
'notes' => $b['notes'] ?? '',
]
];
}, $bookings);
echo json_encode(['success' => true, 'data' => $bookings, 'events' => $events]);
} catch (Exception $e) {
error_log("Bookings list error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Failed to fetch bookings']);
}
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/db.php';
require_once __DIR__ . '/../../includes/auth.php';
header('Content-Type: application/json');
requireLogin();
if (!hasPermission('facilities.view')) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Access denied']);
exit;
}
try {
$where = ['1=1'];
$params = [];
if (isset($_GET['is_active']) && $_GET['is_active'] !== '') {
$where[] = 'f.is_active = :is_active';
$params[':is_active'] = (int)$_GET['is_active'];
}
if (!empty($_GET['facility_type_id'])) {
$where[] = 'f.facility_type_id = :facility_type_id';
$params[':facility_type_id'] = (int)$_GET['facility_type_id'];
}
if (!empty($_GET['search'])) {
$where[] = '(f.name_en LIKE :search OR f.name_ar LIKE :search2 OR f.location LIKE :search3)';
$params[':search'] = '%' . $_GET['search'] . '%';
$params[':search2'] = '%' . $_GET['search'] . '%';
$params[':search3'] = '%' . $_GET['search'] . '%';
}
$whereClause = implode(' AND ', $where);
$sql = "SELECT f.*, ft.name_en AS type_name_en, ft.name_ar AS type_name_ar,
(SELECT COUNT(*) FROM facility_bookings fb
WHERE fb.facility_id = f.id AND fb.status = 'confirmed' AND fb.booking_date >= CURDATE()) AS upcoming_bookings
FROM facilities f
LEFT JOIN facility_types ft ON f.facility_type_id = ft.id
WHERE {$whereClause}
ORDER BY f.name_en ASC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$facilities = $stmt->fetchAll(PDO::FETCH_ASSOC);
$typesStmt = $pdo->query("SELECT id, name_en, name_ar FROM facility_types WHERE is_active = 1 ORDER BY name_en");
$types = $typesStmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'data' => $facilities, 'facility_types' => $types]);
} catch (Exception $e) {
error_log("Facilities list error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Failed to fetch facilities']);
}
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/db.php';
require_once __DIR__ . '/../../includes/auth.php';
header('Content-Type: application/json');
requireLogin();
if (!hasPermission('facilities.manage')) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Access denied']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) $input = $_POST;
if (!validateCsrfToken($input['csrf_token'] ?? '')) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Invalid CSRF token']);
exit;
}
$errors = [];
if (empty($input['name_en'])) $errors[] = 'English name is required';
if (empty($input['name_ar'])) $errors[] = 'Arabic name is required';
if (empty($input['facility_type_id'])) $errors[] = 'Facility type is required';
if (!isset($input['capacity']) || $input['capacity'] < 0) $errors[] = 'Valid capacity is required';
if (!empty($errors)) {
http_response_code(422);
echo json_encode(['success' => false, 'message' => implode(', ', $errors), 'errors' => $errors]);
exit;
}
try {
$sql = "INSERT INTO facilities (facility_type_id, name_ar, name_en, capacity, location, is_active, notes)
VALUES (:facility_type_id, :name_ar, :name_en, :capacity, :location, :is_active, :notes)";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':facility_type_id' => (int)$input['facility_type_id'],
':name_ar' => trim($input['name_ar']),
':name_en' => trim($input['name_en']),
':capacity' => (int)$input['capacity'],
':location' => trim($input['location'] ?? ''),
':is_active' => isset($input['is_active']) ? (int)$input['is_active'] : 1,
':notes' => trim($input['notes'] ?? ''),
]);
$facilityId = $pdo->lastInsertId();
logAudit($pdo, $_SESSION['user_id'], 'CREATE', 'facilities', $facilityId, null, $input);
echo json_encode(['success' => true, 'message' => 'Facility created successfully', 'id' => $facilityId]);
} catch (Exception $e) {
error_log("Facility store error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Failed to create facility']);
}
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/db.php';
require_once __DIR__ . '/../../includes/auth.php';
header('Content-Type: application/json');
requireLogin();
if (!hasPermission('facilities.manage')) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Access denied']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) $input = $_POST;
if (!validateCsrfToken($input['csrf_token'] ?? '')) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Invalid CSRF token']);
exit;
}
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Invalid facility ID']);
exit;
}
$oldStmt = $pdo->prepare("SELECT * FROM facilities WHERE id = :id");
$oldStmt->execute([':id' => $id]);
$oldData = $oldStmt->fetch(PDO::FETCH_ASSOC);
if (!$oldData) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Facility not found']);
exit;
}
$errors = [];
if (empty($input['name_en'])) $errors[] = 'English name is required';
if (empty($input['name_ar'])) $errors[] = 'Arabic name is required';
if (empty($input['facility_type_id'])) $errors[] = 'Facility type is required';
if (!isset($input['capacity']) || $input['capacity'] < 0) $errors[] = 'Valid capacity is required';
if (!empty($errors)) {
http_response_code(422);
echo json_encode(['success' => false, 'message' => implode(', ', $errors), 'errors' => $errors]);
exit;
}
try {
$sql = "UPDATE facilities SET
facility_type_id = :facility_type_id,
name_ar = :name_ar,
name_en = :name_en,
capacity = :capacity,
location = :location,
is_active = :is_active,
notes = :notes
WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':id' => $id,
':facility_type_id' => (int)$input['facility_type_id'],
':name_ar' => trim($input['name_ar']),
':name_en' => trim($input['name_en']),
':capacity' => (int)$input['capacity'],
':location' => trim($input['location'] ?? ''),
':is_active' => isset($input['is_active']) ? (int)$input['is_active'] : 1,
':notes' => trim($input['notes'] ?? ''),
]);
logAudit($pdo, $_SESSION['user_id'], 'UPDATE', 'facilities', $id, $oldData, $input);
echo json_encode(['success' => true, 'message' => 'Facility updated successfully']);
} catch (Exception $e) {
error_log("Facility update error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Failed to update facility']);
}
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
header('Content-Type: application/json; charset=utf-8');
$auth = Auth::getInstance();
$auth->requirePermission('finance.dashboard');
$db = Database::getInstance()->getConnection();
$type = $_GET['type'] ?? '';
try {
switch ($type) {
case 'summary':
echo json_encode(['success' => true, 'data' => getSummary($db)]);
break;
case 'monthly':
$months = intval($_GET['months'] ?? 12);
echo json_encode(['success' => true, 'data' => getMonthlyRevenue($db, $months)]);
break;
case 'payment_methods':
echo json_encode(['success' => true, 'data' => getPaymentMethodBreakdown($db)]);
break;
case 'membership_types':
echo json_encode(['success' => true, 'data' => getMembershipTypeRevenue($db)]);
break;
case 'daily':
echo json_encode(['success' => true, 'data' => getDailyCollections($db)]);
break;
case 'overdue_preview':
echo json_encode(['success' => true, 'data' => getOverduePreview($db)]);
break;
case 'recent_receipts':
echo json_encode(['success' => true, 'data' => getRecentReceipts($db)]);
break;
default:
echo json_encode(['success' => false, 'message' => 'Invalid type']);
}
} catch (Exception $e) {
error_log("Finance dashboard error: " . $e->getMessage());
echo json_encode(['success' => false, 'message' => 'Server error']);
}
function getSummary(PDO $db): array {
$today = date('Y-m-d');
$monthStart = date('Y-m-01');
$yearStart = date('Y-01-01');
// Today's collections
$stmt = $db->prepare("
SELECT COALESCE(SUM(amount), 0) as total, COUNT(*) as cnt
FROM payments
WHERE DATE(payment_date) = ? AND status = 'completed'
");
$stmt->execute([$today]);
$todayData = $stmt->fetch(PDO::FETCH_ASSOC);
// This month
$stmt = $db->prepare("
SELECT COALESCE(SUM(amount), 0) as total, COUNT(*) as cnt
FROM payments
WHERE payment_date >= ? AND status = 'completed'
");
$stmt->execute([$monthStart]);
$monthData = $stmt->fetch(PDO::FETCH_ASSOC);
// This year
$stmt = $db->prepare("
SELECT COALESCE(SUM(amount), 0) as total, COUNT(*) as cnt
FROM payments
WHERE payment_date >= ? AND status = 'completed'
");
$stmt->execute([$yearStart]);
$yearData = $stmt->fetch(PDO::FETCH_ASSOC);
// Outstanding
$stmt = $db->query("
SELECT COALESCE(SUM(mf.amount - mf.paid_amount), 0) as total,
COUNT(DISTINCT mf.member_id) as member_count
FROM member_fees mf
WHERE mf.status IN ('pending', 'partial')
AND mf.amount > mf.paid_amount
");
$outstandingData = $stmt->fetch(PDO::FETCH_ASSOC);
return [
'today_total' => $todayData['total'],
'today_count' => $todayData['cnt'],
'month_total' => $monthData['total'],
'month_count' => $monthData['cnt'],
'year_total' => $yearData['total'],
'year_count' => $yearData['cnt'],
'outstanding_total' => $outstandingData['total'],
'outstanding_members' => $outstandingData['member_count']
];
}
function getMonthlyRevenue(PDO $db, int $months): array {
$stmt = $db->prepare("
SELECT DATE_FORMAT(payment_date, '%Y-%m') as month_key,
DATE_FORMAT(payment_date, '%b %Y') as month_label,
COALESCE(SUM(amount), 0) as total
FROM payments
WHERE payment_date >= DATE_SUB(CURDATE(), INTERVAL ? MONTH)
AND status = 'completed'
GROUP BY month_key, month_label
ORDER BY month_key ASC
");
$stmt->execute([$months]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return [
'labels' => array_column($rows, 'month_label'),
'values' => array_map('floatval', array_column($rows, 'total'))
];
}
function getPaymentMethodBreakdown(PDO $db): array {
$yearStart = date('Y-01-01');
$stmt = $db->prepare("
SELECT COALESCE(payment_method, 'cash') as method,
COALESCE(SUM(amount), 0) as total
FROM payments
WHERE payment_date >= ? AND status = 'completed'
GROUP BY method
ORDER BY total DESC
");
$stmt->execute([$yearStart]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$methodLabels = [
'cash' => 'Cash / نقدي',
'card' => 'Card / بطاقة',
'bank_transfer' => 'Bank Transfer / تحويل',
'check' => 'Check / شيك',
'online' => 'Online / إلكتروني'
];
return [
'labels' => array_map(fn($r) => $methodLabels[$r['method']] ?? ucfirst($r['method']), $rows),
'values' => array_map('floatval', array_column($rows, 'total'))
];
}
function getMembershipTypeRevenue(PDO $db): array {
$yearStart = date('Y-01-01');
$stmt = $db->prepare("
SELECT mt.name_ar as type_name, COALESCE(SUM(p.amount), 0) as total
FROM payments p
JOIN members m ON p.member_id = m.id
JOIN membership_types mt ON m.membership_type_id = mt.id
WHERE p.payment_date >= ? AND p.status = 'completed'
GROUP BY mt.id, mt.name_ar
ORDER BY total DESC
LIMIT 8
");
$stmt->execute([$yearStart]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return [
'labels' => array_column($rows, 'type_name'),
'values' => array_map('floatval', array_column($rows, 'total'))
];
}
function getDailyCollections(PDO $db): array {
$stmt = $db->query("
SELECT DATE(payment_date) as day,
DATE_FORMAT(payment_date, '%d/%m') as day_label,
COALESCE(SUM(amount), 0) as total
FROM payments
WHERE payment_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
AND status = 'completed'
GROUP BY day, day_label
ORDER BY day ASC
");
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return [
'labels' => array_column($rows, 'day_label'),
'values' => array_map('floatval', array_column($rows, 'total'))
];
}
function getOverduePreview(PDO $db): array {
$stmt = $db->query("
SELECT pi.id, pi.amount, pi.due_date,
DATEDIFF(CURDATE(), pi.due_date) as days_late,
m.membership_number,
CONCAT(m.first_name_ar, ' ', m.family_name_ar) as member_name
FROM payment_installments pi
JOIN payment_plans pp ON pi.payment_plan_id = pp.id
JOIN members m ON pp.member_id = m.id
WHERE pi.status IN ('pending', 'overdue')
AND pi.due_date < CURDATE()
ORDER BY pi.due_date ASC
LIMIT 5
");
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function getRecentReceipts(PDO $db): array {
$stmt = $db->query("
SELECT r.receipt_number, r.total_amount as amount,
DATE_FORMAT(r.created_at, '%Y-%m-%d %H:%i') as created_at,
CONCAT(m.first_name_ar, ' ', m.family_name_ar) as member_name
FROM receipts r
LEFT JOIN payments p ON r.payment_id = p.id
LEFT JOIN members m ON p.member_id = m.id
ORDER BY r.created_at DESC
LIMIT 5
");
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
header('Content-Type: application/json; charset=utf-8');
$auth = Auth::getInstance();
$auth->requirePermission('finance.outstanding');
$db = Database::getInstance()->getConnection();
// Summary endpoint
if (isset($_GET['summary'])) {
$stmt = $db->query("
SELECT COALESCE(SUM(mf.amount - mf.paid_amount), 0) as total_outstanding,
COUNT(DISTINCT mf.member_id) as total_members,
COALESCE(AVG(mf.amount - mf.paid_amount), 0) as avg_outstanding
FROM member_fees mf
WHERE mf.status IN ('pending', 'partial') AND mf.amount > mf.paid_amount
");
echo json_encode(['success' => true, 'data' => $stmt->fetch(PDO::FETCH_ASSOC)]);
exit;
}
// Types endpoint
if (isset($_GET['get_types'])) {
$stmt = $db->query("SELECT id, name_ar, name_en FROM membership_types WHERE is_active = 1 ORDER BY sort_order");
echo json_encode(['success' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
exit;
}
// DataTable server-side
$draw = intval($_GET['draw'] ?? 1);
$start = intval($_GET['start'] ?? 0);
$length = intval($_GET['length'] ?? 25);
$search = $_GET['search']['value'] ?? '';
$membershipTypeId = $_GET['membership_type_id'] ?? '';
$minOutstanding = $_GET['min_outstanding'] ?? '';
$where = "WHERE outstanding > 0";
$params = [];
$baseQuery = "
FROM (
SELECT m.id as member_id,
m.membership_number,
CONCAT(m.first_name_ar, ' ', m.family_name_ar) as member_name,
mt.name_ar as membership_type,
mt.id as membership_type_id,
COALESCE(SUM(mf.amount), 0) as total_fees,
COALESCE(SUM(mf.paid_amount), 0) as total_paid,
COALESCE(SUM(mf.amount - mf.paid_amount), 0) as outstanding,
m.mobile_phone as phone,
(SELECT MAX(p.payment_date) FROM payments p WHERE p.member_id = m.id AND p.status = 'completed') as last_payment_date
FROM members m
JOIN membership_types mt ON m.membership_type_id = mt.id
JOIN member_fees mf ON mf.member_id = m.id AND mf.status IN ('pending', 'partial')
GROUP BY m.id, m.membership_number, member_name, mt.name_ar, mt.id, m.mobile_phone
HAVING outstanding > 0
) sub
";
if ($membershipTypeId) {
$where .= " AND membership_type_id = :mt_id";
$params[':mt_id'] = $membershipTypeId;
}
if ($minOutstanding !== '') {
$where .= " AND outstanding >= :min_out";
$params[':min_out'] = floatval($minOutstanding);
}
if ($search) {
$where .= " AND (membership_number LIKE :search OR member_name LIKE :search2 OR phone LIKE :search3)";
$params[':search'] = "%{$search}%";
$params[':search2'] = "%{$search}%";
$params[':search3'] = "%{$search}%";
}
// Total count (unfiltered)
$totalStmt = $db->query("SELECT COUNT(*) FROM ({$baseQuery} WHERE outstanding > 0) t");
$totalRecords = $totalStmt->fetchColumn();
// Filtered count
$filteredStmt = $db->prepare("SELECT COUNT(*) {$baseQuery} {$where}");
$filteredStmt->execute($params);
$filteredRecords = $filteredStmt->fetchColumn();
// Sort
$sortBy = $_GET['sort_by'] ?? 'outstanding_desc';
$orderBy = match($sortBy) {
'outstanding_asc' => 'outstanding ASC',
'name_asc' => 'member_name ASC',
'last_payment' => 'last_payment_date ASC',
default => 'outstanding DESC'
};
// Data
$dataSQL = "SELECT * {$baseQuery} {$where} ORDER BY {$orderBy} LIMIT {$start}, {$length}";
$dataStmt = $db->prepare($dataSQL);
$dataStmt->execute($params);
$rows = $dataStmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode([
'draw' => $draw,
'recordsTotal' => intval($totalRecords),
'recordsFiltered' => intval($filteredRecords),
'data' => $rows
]);
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
header('Content-Type: application/json; charset=utf-8');
$auth = Auth::getInstance();
$auth->requirePermission('finance.overdue');
$db = Database::getInstance()->getConnection();
if (isset($_GET['summary'])) {
$stmt = $db->query("
SELECT COALESCE(SUM(pi.amount), 0) as total_amount,
COUNT(*) as total_installments,
COUNT(DISTINCT pp.member_id) as total_members,
COALESCE(AVG(DATEDIFF(CURDATE(), pi.due_date)), 0) as avg_days_late
FROM payment_installments pi
JOIN payment_plans pp ON pi.payment_plan_id = pp.id
WHERE pi.status IN ('pending', 'overdue') AND pi.due_date < CURDATE()
");
echo json_encode(['success' => true, 'data' => $stmt->fetch(PDO::FETCH_ASSOC)]);
exit;
}
// DataTable
$draw = intval($_GET['draw'] ?? 1);
$start = intval($_GET['start'] ?? 0);
$length = intval($_GET['length'] ?? 25);
$search = $_GET['search']['value'] ?? '';
$baseWhere = "pi.status IN ('pending', 'overdue') AND pi.due_date < CURDATE()";
$params = [];
if ($search) {
$baseWhere .= " AND (m.membership_number LIKE :s OR CONCAT(m.first_name_ar, ' ', m.family_name_ar) LIKE :s2)";
$params[':s'] = "%{$search}%";
$params[':s2'] = "%{$search}%";
}
$countStmt = $db->prepare("
SELECT COUNT(*)
FROM payment_installments pi
JOIN payment_plans pp ON pi.payment_plan_id = pp.id
JOIN members m ON pp.member_id = m.id
WHERE {$baseWhere}
");
$countStmt->execute($params);
$total = $countStmt->fetchColumn();
$dataStmt = $db->prepare("
SELECT pi.id as installment_id,
pi.installment_number,
pi.amount,
DATE_FORMAT(pi.due_date, '%Y-%m-%d') as due_date,
DATEDIFF(CURDATE(), pi.due_date) as days_late,
pi.payment_method,
pp.plan_number,
pp.member_id,
m.membership_number,
CONCAT(m.first_name_ar, ' ', m.family_name_ar) as member_name
FROM payment_installments pi
JOIN payment_plans pp ON pi.payment_plan_id = pp.id
JOIN members m ON pp.member_id = m.id
WHERE {$baseWhere}
ORDER BY pi.due_date ASC
LIMIT {$start}, {$length}
");
$dataStmt->execute($params);
$rows = $dataStmt->fetchAll(PDO::FETCH_ASSOC);
$formatted = array_map(function($row) {
return [
'installment_id' => $row['installment_id'],
'member_id' => $row['member_id'],
'member_info' => ['number' => $row['membership_number'], 'name' => $row['member_name']],
'plan_number' => $row['plan_number'],
'installment_number' => '#' . $row['installment_number'],
'amount' => $row['amount'],
'due_date' => $row['due_date'],
'days_late' => intval($row['days_late']),
'payment_method' => ucfirst($row['payment_method'] ?? 'cash')
];
}, $rows);
echo json_encode([
'draw' => $draw,
'recordsTotal' => intval($total),
'recordsFiltered' => intval($total),
'data' => $formatted
]);
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
header('Content-Type: application/json; charset=utf-8');
$auth = Auth::getInstance();
$auth->requirePermission('finance.receipt_register');
$db = Database::getInstance()->getConnection();
$draw = intval($_GET['draw'] ?? 1);
$start = intval($_GET['start'] ?? 0);
$length = intval($_GET['length'] ?? 25);
$dateFrom = $_GET['date_from'] ?? date('Y-m-01');
$dateTo = $_GET['date_to'] ?? date('Y-m-d');
$searchCustom = $_GET['search_custom'] ?? '';
$paymentMethod = $_GET['payment_method'] ?? '';
$where = "r.created_at BETWEEN :df AND :dt";
$params = [':df' => $dateFrom . ' 00:00:00', ':dt' => $dateTo . ' 23:59:59'];
if ($searchCustom) {
$where .= " AND (r.receipt_number LIKE :sc1 OR m.membership_number LIKE :sc2 OR CONCAT(m.first_name_ar,' ',m.family_name_ar) LIKE :sc3)";
$params[':sc1'] = "%{$searchCustom}%";
$params[':sc2'] = "%{$searchCustom}%";
$params[':sc3'] = "%{$searchCustom}%";
}
if ($paymentMethod) {
$where .= " AND p.payment_method = :pm";
$params[':pm'] = $paymentMethod;
}
$countStmt = $db->prepare("
SELECT COUNT(*)
FROM receipts r
LEFT JOIN payments p ON r.payment_id = p.id
LEFT JOIN members m ON p.member_id = m.id
WHERE {$where}
");
$countStmt->execute($params);
$total = $countStmt->fetchColumn();
$dataStmt = $db->prepare("
SELECT r.id, r.receipt_number, r.total_amount,
DATE_FORMAT(r.created_at, '%Y-%m-%d %H:%i') as receipt_date,
COALESCE(p.payment_method, 'N/A') as payment_method,
m.membership_number, CONCAT(m.first_name_ar, ' ', m.family_name_ar) as member_name,
COALESCE(u.full_name, 'System') as cashier_name,
r.description
FROM receipts r
LEFT JOIN payments p ON r.payment_id = p.id
LEFT JOIN members m ON p.member_id = m.id
LEFT JOIN users u ON r.created_by = u.id
WHERE {$where}
ORDER BY r.created_at DESC
LIMIT {$start}, {$length}
");
$dataStmt->execute($params);
$rows = $dataStmt->fetchAll(PDO::FETCH_ASSOC);
$formatted = array_map(function($row) {
return [
'id' => $row['id'],
'receipt_number' => $row['receipt_number'],
'receipt_date' => $row['receipt_date'],
'member_info' => $row['membership_number'] ? [
'number' => $row['membership_number'],
'name' => $row['member_name']
] : null,
'description' => $row['description'] ?: '-',
'total_amount' => $row['total_amount'],
'payment_method' => ucfirst(str_replace('_', ' ', $row['payment_method'])),
'cashier_name' => $row['cashier_name']
];
}, $rows);
echo json_encode([
'draw' => $draw,
'recordsTotal' => intval($total),
'recordsFiltered' => intval($total),
'data' => $formatted
]);
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
require_once __DIR__ . '/../../includes/audit.php';
header('Content-Type: application/json; charset=utf-8');
$auth = Auth::getInstance();
$auth->requirePermission('finance.reconciliation');
$db = Database::getInstance()->getConnection();
// GET: System totals
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['get_system_totals'])) {
$userId = $auth->getUserId();
$today = date('Y-m-d');
$stmt = $db->prepare("
SELECT
COALESCE(SUM(CASE WHEN payment_method = 'cash' THEN amount ELSE 0 END), 0) as cash,
COALESCE(SUM(CASE WHEN payment_method = 'card' THEN amount ELSE 0 END), 0) as card,
COALESCE(SUM(CASE WHEN payment_method = 'bank_transfer' THEN amount ELSE 0 END), 0) as bank_transfer,
COALESCE(SUM(CASE WHEN payment_method = 'check' THEN amount ELSE 0 END), 0) as check_amount,
COALESCE(SUM(amount), 0) as grand_total
FROM payments
WHERE created_by = ? AND DATE(payment_date) = ? AND status = 'completed'
");
$stmt->execute([$userId, $today]);
echo json_encode(['success' => true, 'data' => $stmt->fetch(PDO::FETCH_ASSOC)]);
exit;
}
// POST: Save reconciliation
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
verify_csrf_token($input['csrf_token'] ?? '');
$userId = $auth->getUserId();
$today = date('Y-m-d');
// Check duplicate
$checkStmt = $db->prepare("SELECT id FROM cash_reconciliations WHERE cashier_id = ? AND reconciliation_date = ?");
$checkStmt->execute([$userId, $today]);
if ($checkStmt->fetch()) {
echo json_encode(['success' => false, 'message' => 'Reconciliation already submitted for today']);
exit;
}
try {
$systemTotal = floatval($input['system_total'] ?? 0);
$systemCash = floatval($input['system_cash'] ?? 0);
$systemCard = floatval($input['system_card'] ?? 0);
$systemBank = floatval($input['system_bank_transfer'] ?? 0);
$systemCheck = floatval($input['system_check'] ?? 0);
$actualTotal = floatval($input['actual_total'] ?? 0);
$difference = $actualTotal - $systemCash;
$notes = trim($input['notes'] ?? '');
// Notes required if difference
if (abs($difference) > 0.01 && !$notes) {
echo json_encode(['success' => false, 'message' => 'Notes required when difference exists']);
exit;
}
$db->beginTransaction();
$stmt = $db->prepare("
INSERT INTO cash_reconciliations
(cashier_id, reconciliation_date, system_total, system_cash, system_card, system_bank_transfer, system_check,
actual_total, denomination_200, denomination_100, denomination_50, denomination_20, denomination_10, denomination_5, denomination_1,
coins, difference, notes, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'submitted', NOW())
");
$stmt->execute([
$userId, $today, $systemTotal, $systemCash, $systemCard, $systemBank, $systemCheck,
$actualTotal,
intval($input['denomination_200'] ?? 0),
intval($input['denomination_100'] ?? 0),
intval($input['denomination_50'] ?? 0),
intval($input['denomination_20'] ?? 0),
intval($input['denomination_10'] ?? 0),
intval($input['denomination_5'] ?? 0),
intval($input['denomination_1'] ?? 0),
floatval($input['coins'] ?? 0),
$difference, $notes
]);
$reconId = $db->lastInsertId();
AuditTrail::log($db, 'reconciliation_submitted', 'cash_reconciliations', $reconId, null, [
'system_cash' => $systemCash,
'actual_total' => $actualTotal,
'difference' => $difference
]);
$db->commit();
echo json_encode([
'success' => true,
'message' => 'Reconciliation submitted successfully / تم تقديم التسوية بنجاح',
'data' => ['id' => $reconId, 'difference' => $difference]
]);
} catch (Exception $e) {
$db->rollBack();
error_log("Reconciliation save error: " . $e->getMessage());
echo json_encode(['success' => false, 'message' => 'Server error']);
}
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
require_once __DIR__ . '/../../includes/audit.php';
header('Content-Type: application/json; charset=utf-8');
$auth = Auth::getInstance();
$auth->requirePermission('finance.refund_approve');
$db = Database::getInstance()->getConnection();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
verify_csrf_token($input['csrf_token'] ?? '');
$refundId = intval($input['refund_id'] ?? 0);
$action = $input['action'] ?? '';
if (!$refundId || !in_array($action, ['approve', 'reject'])) {
echo json_encode(['success' => false, 'message' => 'Invalid parameters']);
exit;
}
try {
$stmt = $db->prepare("SELECT * FROM refunds WHERE id = ? AND status = 'pending'");
$stmt->execute([$refundId]);
$refund = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$refund) {
echo json_encode(['success' => false, 'message' => 'Refund not found or not in pending status']);
exit;
}
$db->beginTransaction();
if ($action === 'approve') {
$stmt = $db->prepare("UPDATE refunds SET status = 'approved', approved_by = ?, approved_at = NOW(), updated_at = NOW() WHERE id = ?");
$stmt->execute([$auth->getUserId(), $refundId]);
AuditTrail::log($db, 'refund_approved', 'refunds', $refundId,
['status' => 'pending'],
['status' => 'approved', 'approved_by' => $auth->getUserId()]
);
$msg = "Refund {$refund['refund_number']} approved / تم اعتماد الاسترداد";
} else {
$rejectionReason = trim($input['rejection_reason'] ?? '');
if (!$rejectionReason) {
$db->rollBack();
echo json_encode(['success' => false, 'message' => 'Rejection reason is required']);
exit;
}
$stmt = $db->prepare("UPDATE refunds SET status = 'rejected', rejection_reason = ?, approved_by = ?, approved_at = NOW(), updated_at = NOW() WHERE id = ?");
$stmt->execute([$rejectionReason, $auth->getUserId(), $refundId]);
AuditTrail::log($db, 'refund_rejected', 'refunds', $refundId,
['status' => 'pending'],
['status' => 'rejected', 'reason' => $rejectionReason]
);
$msg = "Refund {$refund['refund_number']} rejected / تم رفض الاسترداد";
}
$db->commit();
echo json_encode(['success' => true, 'message' => $msg]);
} catch (Exception $e) {
$db->rollBack();
error_log("Refund approve error: " . $e->getMessage());
echo json_encode(['success' => false, 'message' => 'Server error']);
}
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
header('Content-Type: application/json; charset=utf-8');
$auth = Auth::getInstance();
$auth->requirePermission('finance.refund_request');
$db = Database::getInstance()->getConnection();
$draw = intval($_GET['draw'] ?? 1);
$start = intval($_GET['start'] ?? 0);
$length = intval($_GET['length'] ?? 25);
$search = $_GET['search']['value'] ?? '';
$status = $_GET['status'] ?? '';
$where = '1=1';
$params = [];
if ($status) {
$where .= " AND ref.status = :status";
$params[':status'] = $status;
}
if ($search) {
$where .= " AND (ref.refund_number LIKE :s1 OR m.membership_number LIKE :s2 OR CONCAT(m.first_name_ar,' ',m.family_name_ar) LIKE :s3)";
$params[':s1'] = "%{$search}%";
$params[':s2'] = "%{$search}%";
$params[':s3'] = "%{$search}%";
}
$countStmt = $db->prepare("
SELECT COUNT(*) FROM refunds ref
JOIN members m ON ref.member_id = m.id
WHERE {$where}
");
$countStmt->execute($params);
$total = $countStmt->fetchColumn();
$dataStmt = $db->prepare("
SELECT ref.id, ref.refund_number, ref.refund_amount, ref.refund_method, ref.status,
DATE_FORMAT(ref.created_at, '%Y-%m-%d %H:%i') as created_at,
CONCAT(m.first_name_ar, ' ', m.family_name_ar) as member_name,
COALESCE(r.receipt_number, CONCAT('PAY-', ref.original_payment_id)) as original_receipt
FROM refunds ref
JOIN members m ON ref.member_id = m.id
LEFT JOIN receipts r ON r.payment_id = ref.original_payment_id
WHERE {$where}
ORDER BY ref.created_at DESC
LIMIT {$start}, {$length}
");
$dataStmt->execute($params);
echo json_encode([
'draw' => $draw,
'recordsTotal' => intval($total),
'recordsFiltered' => intval($total),
'data' => $dataStmt->fetchAll(PDO::FETCH_ASSOC)
]);
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
require_once __DIR__ . '/../../includes/audit.php';
header('Content-Type: application/json; charset=utf-8');
$auth = Auth::getInstance();
$auth->requirePermission('finance.refund_process');
$db = Database::getInstance()->getConnection();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
verify_csrf_token($input['csrf_token'] ?? '');
$refundId = intval($input['refund_id'] ?? 0);
if (!$refundId) {
echo json_encode(['success' => false, 'message' => 'Refund ID required']);
exit;
}
try {
$stmt = $db->prepare("SELECT * FROM refunds WHERE id = ? AND status = 'approved'");
$stmt->execute([$refundId]);
$refund = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$refund) {
echo json_encode(['success' => false, 'message' => 'Refund not found or not approved']);
exit;
}
$db->beginTransaction();
// Update refund status
$stmt = $db->prepare("UPDATE refunds SET status = 'processed', processed_by = ?, processed_at = NOW(), updated_at = NOW() WHERE id = ?");
$stmt->execute([$auth->getUserId(), $refundId]);
// Update member fees - reduce paid_amount for related fees
$remainingRefund = floatval($refund['refund_amount']);
// Get payment items to know which fees were paid
$itemStmt = $db->prepare("
SELECT pi.*, mf.id as fee_id, mf.paid_amount as fee_paid
FROM payment_items pi
JOIN member_fees mf ON pi.member_fee_id = mf.id
WHERE pi.payment_id = ?
ORDER BY pi.id DESC
");
$itemStmt->execute([$refund['original_payment_id']]);
$items = $itemStmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($items as $item) {
if ($remainingRefund <= 0) break;
$refundFromItem = min($remainingRefund, floatval($item['amount']));
// Reduce paid_amount on member_fee
$newPaid = max(0, floatval($item['fee_paid']) - $refundFromItem);
$feeStmt = $db->prepare("
UPDATE member_fees SET paid_amount = ?,
status = CASE WHEN ? <= 0 THEN 'pending' WHEN ? < amount THEN 'partial' ELSE 'paid' END,
updated_at = NOW()
WHERE id = ?
");
$feeStmt->execute([$newPaid, $newPaid, $newPaid, $item['fee_id']]);
$remainingRefund -= $refundFromItem;
}
AuditTrail::log($db, 'refund_processed', 'refunds', $refundId,
['status' => 'approved'],
['status' => 'processed', 'processed_by' => $auth->getUserId(), 'amount' => $refund['refund_amount']]
);
$db->commit();
echo json_encode([
'success' => true,
'message' => "Refund {$refund['refund_number']} processed successfully. Member fees adjusted. / تم تنفيذ الاسترداد وتعديل الأرصدة"
]);
} catch (Exception $e) {
$db->rollBack();
error_log("Refund process error: " . $e->getMessage());
echo json_encode(['success' => false, 'message' => 'Server error processing refund']);
}
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
require_once __DIR__ . '/../../includes/audit.php';
require_once __DIR__ . '/../../includes/number-series.php';
header('Content-Type: application/json; charset=utf-8');
$auth = Auth::getInstance();
$auth->requirePermission('finance.refund_request');
$db = Database::getInstance()->getConnection();
// Get member payments for refund
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['get_payments'])) {
$memberId = intval($_GET['member_id']);
$stmt = $db->prepare("
SELECT p.id, p.amount, p.payment_method,
DATE_FORMAT(p.payment_date, '%Y-%m-%d') as payment_date,
r.receipt_number,
COALESCE((SELECT SUM(ref.refund_amount) FROM refunds ref
WHERE ref.original_payment_id = p.id AND ref.status IN ('approved','processed')), 0) as refunded_amount
FROM payments p
LEFT JOIN receipts r ON r.payment_id = p.id
WHERE p.member_id = ? AND p.status = 'completed'
HAVING (p.amount - refunded_amount) > 0
ORDER BY p.payment_date DESC
LIMIT 50
");
$stmt->execute([$memberId]);
echo json_encode(['success' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
exit;
}
// POST: Create refund request
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
verify_csrf_token($input['csrf_token'] ?? '');
$memberId = intval($input['member_id'] ?? 0);
$paymentId = intval($input['original_payment_id'] ?? 0);
$refundAmount = floatval($input['refund_amount'] ?? 0);
$refundMethod = trim($input['refund_method'] ?? 'cash');
$reason = trim($input['reason'] ?? '');
// Validation
$errors = [];
if (!$memberId) $errors[] = 'Member is required';
if (!$paymentId) $errors[] = 'Original payment is required';
if ($refundAmount <= 0) $errors[] = 'Refund amount must be positive';
if (!$reason) $errors[] = 'Reason is required';
if (!in_array($refundMethod, ['cash', 'bank_transfer', 'card'])) $errors[] = 'Invalid refund method';
if ($errors) {
echo json_encode(['success' => false, 'message' => implode(', ', $errors)]);
exit;
}
try {
// Verify payment exists and belongs to member
$payStmt = $db->prepare("SELECT id, amount, member_id FROM payments WHERE id = ? AND member_id = ? AND status = 'completed'");
$payStmt->execute([$paymentId, $memberId]);
$payment = $payStmt->fetch(PDO::FETCH_ASSOC);
if (!$payment) {
echo json_encode(['success' => false, 'message' => 'Payment not found or does not belong to member']);
exit;
}
// Check max refundable
$refundedStmt = $db->prepare("
SELECT COALESCE(SUM(refund_amount), 0) FROM refunds
WHERE original_payment_id = ? AND status IN ('pending', 'approved', 'processed')
");
$refundedStmt->execute([$paymentId]);
$alreadyRefunded = floatval($refundedStmt->fetchColumn());
$maxRefundable = floatval($payment['amount']) - $alreadyRefunded;
if ($refundAmount > $maxRefundable) {
echo json_encode(['success' => false, 'message' => "Refund amount exceeds maximum refundable: EGP " . number_format($maxRefundable, 2)]);
exit;
}
$db->beginTransaction();
$refundNumber = NumberSeries::generate($db, 'refund');
$stmt = $db->prepare("
INSERT INTO refunds (refund_number, member_id, original_payment_id, refund_amount, refund_method, reason, status, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, NOW())
");
$stmt->execute([$refundNumber, $memberId, $paymentId, $refundAmount, $refundMethod, $reason, $auth->getUserId()]);
$refundId = $db->lastInsertId();
AuditTrail::log($db, 'refund_request', 'refunds', $refundId, null, [
'refund_number' => $refundNumber,
'member_id' => $memberId,
'payment_id' => $paymentId,
'amount' => $refundAmount,
'method' => $refundMethod
]);
$db->commit();
echo json_encode([
'success' => true,
'message' => "Refund request {$refundNumber} created successfully / تم إنشاء طلب الاسترداد بنجاح",
'data' => ['id' => $refundId, 'refund_number' => $refundNumber]
]);
} catch (Exception $e) {
$db->rollBack();
error_log("Refund create error: " . $e->getMessage());
echo json_encode(['success' => false, 'message' => 'Server error creating refund']);
}
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
header('Content-Type: application/json; charset=utf-8');
$auth = Auth::getInstance();
$auth->requirePermission('finance.revenue_report');
$db = Database::getInstance()->getConnection();
$dateFrom = $_GET['date_from'] ?? date('Y-m-01');
$dateTo = $_GET['date_to'] ?? date('Y-m-d');
$membershipTypeId = $_GET['membership_type_id'] ?? '';
$feeTypeId = $_GET['fee_type_id'] ?? '';
$paymentMethod = $_GET['payment_method'] ?? '';
$grouping = $_GET['grouping'] ?? 'month';
// Validate dates
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) {
echo json_encode(['success' => false, 'message' => 'Invalid date format']);
exit;
}
try {
// Build WHERE clause
$where = "p.payment_date BETWEEN :date_from AND :date_to AND p.status = 'completed'";
$params = [':date_from' => $dateFrom, ':date_to' => $dateTo . ' 23:59:59'];
$joins = '';
if ($membershipTypeId) {
$joins .= ' JOIN members m ON p.member_id = m.id';
$where .= ' AND m.membership_type_id = :mt_id';
$params[':mt_id'] = $membershipTypeId;
}
if ($feeTypeId) {
$joins .= ' LEFT JOIN payment_items pi_ft ON pi_ft.payment_id = p.id';
$joins .= ' LEFT JOIN member_fees mf_ft ON pi_ft.member_fee_id = mf_ft.id';
$where .= ' AND mf_ft.fee_type_id = :ft_id';
$params[':ft_id'] = $feeTypeId;
}
if ($paymentMethod) {
$where .= ' AND p.payment_method = :pm';
$params[':pm'] = $paymentMethod;
}
// Group expression
$groupExpr = match($grouping) {
'day' => "DATE(p.payment_date)",
'week' => "YEARWEEK(p.payment_date, 1)",
'month' => "DATE_FORMAT(p.payment_date, '%Y-%m')",
'quarter' => "CONCAT(YEAR(p.payment_date), '-Q', QUARTER(p.payment_date))",
'year' => "YEAR(p.payment_date)",
default => "DATE_FORMAT(p.payment_date, '%Y-%m')"
};
$labelExpr = match($grouping) {
'day' => "DATE_FORMAT(p.payment_date, '%Y-%m-%d')",
'week' => "CONCAT('Week ', WEEK(p.payment_date, 1), ' / ', YEAR(p.payment_date))",
'month' => "DATE_FORMAT(p.payment_date, '%b %Y')",
'quarter' => "CONCAT(YEAR(p.payment_date), '-Q', QUARTER(p.payment_date))",
'year' => "CAST(YEAR(p.payment_date) AS CHAR)",
default => "DATE_FORMAT(p.payment_date, '%b %Y')"
};
// Summary
$summarySQL = "
SELECT COALESCE(SUM(p.amount), 0) as total,
COUNT(*) as cnt,
COALESCE(AVG(p.amount), 0) as average,
COUNT(DISTINCT p.member_id) as unique_members
FROM payments p {$joins}
WHERE {$where}
";
$stmt = $db->prepare($summarySQL);
$stmt->execute($params);
$summary = $stmt->fetch(PDO::FETCH_ASSOC);
// Grouped data
$groupSQL = "
SELECT {$groupExpr} as group_key,
{$labelExpr} as label,
COALESCE(SUM(p.amount), 0) as total,
COUNT(*) as count,
COALESCE(AVG(p.amount), 0) as average
FROM payments p {$joins}
WHERE {$where}
GROUP BY group_key, label
ORDER BY group_key ASC
";
$stmt = $db->prepare($groupSQL);
$stmt->execute($params);
$groups = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode([
'success' => true,
'data' => [
'summary' => [
'total' => floatval($summary['total']),
'count' => intval($summary['cnt']),
'average' => floatval($summary['average']),
'unique_members' => intval($summary['unique_members'])
],
'groups' => $groups
]
]);
} catch (Exception $e) {
error_log("Revenue report error: " . $e->getMessage());
echo json_encode(['success' => false, 'message' => 'Server error generating report']);
}
\ No newline at end of file
<?php
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/auth.php';
require_once __DIR__ . '/../../includes/functions.php';
header('Content-Type: application/json; charset=utf-8');
$auth = Auth::getInstance();
$auth->requirePermission('finance.statements');
$db = Database::getInstance()->getConnection();
$dateFrom = $_GET['date_from'] ?? '';
$dateTo = $_GET['date_to'] ?? '';
if (!$dateFrom || !$dateTo) {
echo json_encode(['success' => false, 'message' => 'Date range required']);
exit;
}
try {
// Income by fee type
$incomeStmt = $db->prepare("
SELECT COALESCE(ft.name_ar, 'Other / أخرى') as category,
COALESCE(SUM(pi.amount), 0) as total
FROM payment_items pi
JOIN payments p ON pi.payment_id = p.id
LEFT JOIN member_fees mf ON pi.member_fee_id = mf.id
LEFT JOIN fee_types ft ON mf.fee_type_id = ft.id
WHERE p.payment_date BETWEEN ? AND ?
AND p.status = 'completed'
GROUP BY ft.id, ft.name_ar
ORDER BY total DESC
");
$incomeStmt->execute([$dateFrom, $dateTo . ' 23:59:59']);
$income = $incomeStmt->fetchAll(PDO::FETCH_ASSOC);
// Fallback if no payment_items join — use payments directly
if (empty($income)) {
$incomeStmt = $db->prepare("
SELECT 'Membership Fees / رسوم العضوية' as category,
COALESCE(SUM(amount), 0) as total
FROM payments
WHERE payment_date BETWEEN ? AND ? AND status = 'completed'
");
$incomeStmt->execute([$dateFrom, $dateTo . ' 23:59:59']);
$income = $incomeStmt->fetchAll(PDO::FETCH_ASSOC);
}
// By payment method
$methodStmt = $db->prepare("
SELECT COALESCE(payment_method, 'cash') as method,
COALESCE(SUM(amount), 0) as total,
COUNT(*) as count
FROM payments
WHERE payment_date BETWEEN ? AND ? AND status = 'completed'
GROUP BY payment_method
ORDER BY total DESC
");
$methodStmt->execute([$dateFrom, $dateTo . ' 23:59:59']);
$methods = $methodStmt->fetchAll(PDO::FETCH_ASSOC);
// Format method labels
$methodLabels = ['cash'=>'Cash / نقدي', 'card'=>'Card / بطاقة', 'bank_transfer'=>'Bank Transfer / تحويل', 'check'=>'Check / شيك'];
foreach ($methods as &$m) {
$m['method'] = $methodLabels[$m['method']] ?? ucfirst($m['method']);
}
// Refunds
$refundStmt = $db->prepare("
SELECT COALESCE(SUM(refund_amount), 0) as total, COUNT(*) as cnt
FROM refunds
WHERE status = 'processed' AND processed_at BETWEEN ? AND ?
");
$refundStmt->execute([$dateFrom, $dateTo . ' 23:59:59']);
$refunds = $refundStmt->fetch(PDO::FETCH_ASSOC);
echo json_encode([
'success' => true,
'data' => [
'income' => $income,
'methods' => $methods,
'refunds_total' => $refunds['total'],
'refunds_count' => $refunds['cnt']
]
]);
} catch (Exception $e) {
error_log("Statement generate error: " . $e->getMessage());
echo json_encode(['success' => false, 'message' => 'Server error generating statement']);
}
\ No newline at end of file
<?php
/**
* Gate Access Log List API — Server-side DataTables
* GET: Returns paginated gate access log
* Permission: gates.view_log
*/
require_once __DIR__ . '/../../config/bootstrap.php';
Security::requirePermission('gates.view_log');
try {
$db = Database::getInstance()->getConnection();
$draw = intval($_GET['draw'] ?? 1);
$start = intval($_GET['start'] ?? 0);
$length = intval($_GET['length'] ?? 25);
$search = trim($_GET['search']['value'] ?? '');
$orderCol = intval($_GET['order'][0]['column'] ?? 0);
$orderDir = strtolower($_GET['order'][0]['dir'] ?? 'desc') === 'asc' ? 'ASC' : 'DESC';
$filterGate = intval($_GET['filter_gate'] ?? 0);
$filterGranted = $_GET['filter_granted'] ?? '';
$filterDateFrom = trim($_GET['filter_date_from'] ?? '');
$filterDateTo = trim($_GET['filter_date_to'] ?? '');
$filterType = trim($_GET['filter_access_type'] ?? '');
$filterMemberId = intval($_GET['filter_member_id'] ?? 0);
$columns = [
'gal.id',
'gal.access_time',
'mc.card_number',
'COALESCE(m.full_name_ar, m.full_name_en)',
'g.gate_name_en',
'gal.access_type',
'gal.access_granted',
'gal.denial_reason'
];
$orderByColumn = $columns[$orderCol] ?? 'gal.access_time';
$baseQuery = "
FROM gate_access_log gal
LEFT JOIN member_cards mc ON gal.card_id = mc.id
LEFT JOIN members m ON gal.member_id = m.id
LEFT JOIN gates g ON gal.gate_id = g.id
WHERE 1=1
";
$params = [];
if ($search !== '') {
$baseQuery .= " AND (
mc.card_number LIKE :search
OR m.membership_number LIKE :search2
OR m.full_name_ar LIKE :search3
OR m.full_name_en LIKE :search4
OR g.gate_name_en LIKE :search5
OR g.gate_name_ar LIKE :search6
OR gal.denial_reason LIKE :search7
)";
$sp = "%{$search}%";
$params[':search'] = $sp;
$params[':search2'] = $sp;
$params[':search3'] = $sp;
$params[':search4'] = $sp;
$params[':search5'] = $sp;
$params[':search6'] = $sp;
$params[':search7'] = $sp;
}
if ($filterGate > 0) {
$baseQuery .= " AND gal.gate_id = :gate_id";
$params[':gate_id'] = $filterGate;
}
if ($filterGranted !== '') {
$baseQuery .= " AND gal.access_granted = :granted";
$params[':granted'] = intval($filterGranted);
}
if ($filterDateFrom !== '') {
$baseQuery .= " AND DATE(gal.access_time) >= :date_from";
$params[':date_from'] = $filterDateFrom;
}
if ($filterDateTo !== '') {
$baseQuery .= " AND DATE(gal.access_time) <= :date_to";
$params[':date_to'] = $filterDateTo;
}
if ($filterType !== '') {
$baseQuery .= " AND gal.access_type = :access_type";
$params[':access_type'] = $filterType;
}
if ($filterMemberId > 0) {
$baseQuery .= " AND gal.member_id = :member_id";
$params[':member_id'] = $filterMemberId;
}
// Total
$stmtTotal = $db->prepare("SELECT COUNT(*) FROM gate_access_log gal");
$stmtTotal->execute();
$totalRecords = $stmtTotal->fetchColumn();
// Filtered
$stmtFiltered = $db->prepare("SELECT COUNT(*) {$baseQuery}");
$stmtFiltered->execute($params);
$filteredRecords = $stmtFiltered->fetchColumn();
// Data
$dataQuery = "
SELECT
gal.id,
gal.card_id,
gal.member_id,
gal.gate_id,
gal.access_type,
gal.access_time,
gal.access_granted,
gal.denial_reason,
mc.card_number,
m.membership_number,
m.full_name_ar AS member_name_ar,
m.full_name_en AS member_name_en,
g.gate_name_ar,
g.gate_name_en,
g.gate_code,
g.gate_type
{$baseQuery}
ORDER BY {$orderByColumn} {$orderDir}
LIMIT :offset, :limit
";
$stmtData = $db->prepare($dataQuery);
foreach ($params as $key => $val) {
$stmtData->bindValue($key, $val);
}
$stmtData->bindValue(':offset', $start, PDO::PARAM_INT);
$stmtData->bindValue(':limit', $length, PDO::PARAM_INT);
$stmtData->execute();
$data = [];
foreach ($stmtData->fetchAll(PDO::FETCH_ASSOC) as $row) {
$data[] = [
'id' => (int)$row['id'],
'access_time' => $row['access_time'],
'card_number' => $row['card_number'],
'member_name' => $row['member_name_ar'] ?: $row['member_name_en'],
'membership_number'=> $row['membership_number'],
'gate_name' => $row['gate_name_ar'] ?: $row['gate_name_en'],
'gate_code' => $row['gate_code'],
'gate_type' => $row['gate_type'],
'access_type' => $row['access_type'],
'access_granted' => (bool)$row['access_granted'],
'denial_reason' => $row['denial_reason'],
];
}
echo json_encode([
'draw' => $draw,
'recordsTotal' => (int)$totalRecords,
'recordsFiltered' => (int)$filteredRecords,
'data' => $data,
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => 'Failed to retrieve access log.', 'detail' => $e->getMessage()]);
}
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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